30. Introducing Classes and Objects
30.1) The Object-Oriented Programming Idea (Building Your Own Types)
Throughout this book, you've been working with Python's built-in types: integers, strings, lists, dictionaries, and more. Each type bundles together data (like the characters in a string) with operations you can perform on that data (like .upper() or .split()). This combination of data and behavior is powerful—it lets you think about strings as complete entities with their own capabilities, not just as raw character sequences.
Object-oriented programming (OOP) extends this idea: it lets you create your own custom types, called classes, that bundle together data and behavior specific to your problem domain. Just as Python provides a str type for working with text and a list type for working with sequences, you can create a BankAccount type for managing financial transactions, a Student type for tracking academic records, or a Product type for an inventory system.
Why Create Your Own Types?
Consider managing information about students in a school system. Without classes, you might use separate variables or dictionaries:
# Using separate variables - gets messy quickly
student1_name = "Alice Johnson"
student1_id = "S12345"
student1_gpa = 3.8
student2_name = "Bob Smith"
student2_id = "S12346"
student2_gpa = 3.5
# Or using dictionaries - better, but still limited
student1 = {"name": "Alice Johnson", "id": "S12345", "gpa": 3.8}
student2 = {"name": "Bob Smith", "id": "S12346", "gpa": 3.5}This approach works for simple cases, but it has limitations:
- No validation: Nothing prevents you from setting
gpato an invalid value like-5.0or"excellent" - No related behavior: Operations like calculating honors status or formatting student information are separate functions scattered throughout your code
- No type checking: A dictionary representing a student looks identical to any other dictionary—Python can't help you catch mistakes where you accidentally use a product dictionary where a student dictionary was expected
Classes solve these problems by letting you define a new type that represents exactly what a student is and what operations make sense for students:
# We'll build toward this - a Student class that bundles data and behavior
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
def is_honors(self):
return self.gpa >= 3.5
def display_info(self):
status = "Honors" if self.is_honors() else "Regular"
return f"{self.name} ({self.student_id}) - GPA: {self.gpa} [{status}]"
# Now we can create student objects
alice = Student("Alice Johnson", "S12345", 3.8)
bob = Student("Bob Smith", "S12346", 3.5)
print(alice.display_info()) # Output: Alice Johnson (S12345) - GPA: 3.8 [Honors]
print(bob.is_honors()) # Output: TrueThis chapter will teach you how to build classes like this from the ground up. We'll start with the simplest possible classes and gradually add features until you can create rich, useful custom types.
Classes vs Instances: The Blueprint Analogy
Understanding the distinction between a class and an instance is fundamental to object-oriented programming:
-
A class is like a blueprint or template. It defines what data a type of object will hold and what operations it can perform. The class itself isn't a specific student—it's the definition of what it means to be a student.
-
An instance (also called an object) is a specific example created from that blueprint. When you create
alice = Student("Alice Johnson", "S12345", 3.8), you're creating one specific student instance with Alice's particular data.
You can create as many instances as you need from a single class, just as an architect can use one blueprint to construct many houses. Each instance has its own data (Alice's GPA is different from Bob's), but they all share the same structure and capabilities defined by the class.
What You'll Learn in This Chapter
This chapter introduces the core concepts of object-oriented programming in Python:
- Defining classes with the
classkeyword - Creating instances and accessing their attributes
- Adding methods that operate on instance data
- Understanding
selfand how methods access instance data - Initializing instances with the
__init__method - Controlling string representations with
__str__and__repr__ - Creating multiple independent instances from the same class
By the end of this chapter, you'll be able to design and implement your own custom types that make your programs more organized, maintainable, and expressive. We'll build on these foundations in Chapter 31 with more advanced class features, and in Chapter 32 with inheritance and polymorphism.
30.2) Defining Simple Classes with class
Let's start by creating the simplest possible class—one that just defines a new type without any data or behavior yet.
The class Keyword
You define a class using the class keyword, followed by the class name and a colon:
class Student:
pass # Empty class for now
# Create an instance
alice = Student()
print(alice) # Output: <__main__.Student object at 0x...>
print(type(alice)) # Output: <class '__main__.Student'>Even this minimal class is useful—it creates a new type called Student. When you create an instance with alice = Student(), Python creates a new object of type Student. The output shows that alice is indeed a Student object, though it doesn't do anything interesting yet.
Class Naming Conventions
Python class names follow a specific convention called CapWords or PascalCase: each word starts with a capital letter, with no underscores between words:
class BankAccount: # Good: CapWords
pass
class ProductInventory: # Good: CapWords
pass
class HTTPRequest: # Good: Acronyms are all caps
pass
# Avoid these styles for classes:
# class bank_account: # Wrong: snake_case is for functions/variables
# class bankaccount: # Wrong: hard to read
# class BANKACCOUNT: # Wrong: ALL_CAPS is for constantsThis convention helps distinguish classes from functions and variables (which use snake_case) when reading code.
Creating Instances
Creating an instance from a class looks like calling a function—you use the class name followed by parentheses:
class Product:
pass
# Create three different product instances
item1 = Product()
item2 = Product()
item3 = Product()
# Each instance is a separate object
print(item1) # Output: <__main__.Product object at 0x...>
print(item2) # Output: <__main__.Product object at 0x...>
print(item3) # Output: <__main__.Product object at 0x...>
# They're different objects, even though they're the same type
print(item1 is item2) # Output: False
print(type(item1) is type(item2)) # Output: TrueEach call to Product() creates a new, independent instance. The memory addresses (the 0x... part) are different, confirming these are separate objects in memory.
Why Start with Empty Classes?
You might wonder why we're starting with classes that don't do anything. There are two reasons:
-
Conceptual clarity: Understanding that a class is just a new type, separate from its data and behavior, helps you grasp the fundamental concept before adding complexity.
-
Practical use: Even empty classes can be useful as markers or placeholders. For example, you might define custom exception types:
class InvalidGradeError:
pass
class StudentNotFoundError:
pass
# These empty classes serve as distinct error typesHowever, empty classes are rare in real code. Let's add some data to make our classes useful.
30.3) Creating Instances and Accessing Attributes
Classes become useful when they hold data. In Python, you can add attributes (data attached to an instance) at any time by simply assigning to them.
Adding Attributes to Instances
You can add attributes to an instance using dot notation:
class Student:
pass
# Create an instance
alice = Student()
# Add attributes
alice.name = "Alice Johnson"
alice.student_id = "S12345"
alice.gpa = 3.8
# Access attributes
print(alice.name) # Output: Alice Johnson
print(alice.student_id) # Output: S12345
print(alice.gpa) # Output: 3.8The dot (.) operator accesses attributes: alice.name means "get the name attribute of the alice object." This is the same syntax you've been using with strings (like text.upper()) and lists (like numbers.append(5))—those are accessing methods and attributes of those objects.
Each Instance Has Its Own Attributes
Different instances of the same class have independent attributes:
class Student:
pass
# Create two students
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
bob = Student()
bob.name = "Bob Smith"
bob.gpa = 3.5
# Each instance has its own data
print(alice.name) # Output: Alice Johnson
print(bob.name) # Output: Bob Smith
# Changing one doesn't affect the other
alice.gpa = 3.9
print(alice.gpa) # Output: 3.9
print(bob.gpa) # Output: 3.5 (unchanged)This independence is crucial: alice and bob are separate objects with separate data. Modifying alice.gpa doesn't affect bob.gpa.
Attributes Can Be Any Type
Attributes aren't limited to simple types—they can hold any Python value:
class Student:
pass
student = Student()
student.name = "Carol Davis"
student.grades = [95, 88, 92, 90] # List attribute
student.contact = { # Dictionary attribute
"email": "carol@example.com",
"phone": "555-0123"
}
student.is_active = True # Boolean attribute
# Access nested data
print(student.grades[0]) # Output: 95
print(student.contact["email"]) # Output: carol@example.comThis flexibility lets you model complex real-world entities with rich data structures.
Accessing Non-Existent Attributes
Trying to access an attribute that doesn't exist raises an AttributeError:
class Student:
pass
student = Student()
student.name = "David Lee"
print(student.name) # Output: David Lee
# print(student.age) # AttributeError: 'Student' object has no attribute 'age'This error is helpful—it catches typos and logic errors where you expect an attribute to exist but it doesn't.
The Problem with Manual Attribute Assignment
While you can add attributes manually after creating an instance, this approach has serious drawbacks:
class Student:
pass
# Easy to forget attributes or misspell them
alice = Student()
alice.name = "Alice Johnson"
alice.student_id = "S12345"
# Forgot to set gpa!
bob = Student()
bob.name = "Bob Smith"
bob.stuent_id = "S12346" # Typo: stuent instead of student
bob.gpa = 3.5
# Now alice is missing gpa, and bob has a typo
# print(alice.gpa) # AttributeError
# print(bob.student_id) # AttributeErrorThis is error-prone and tedious. You need a way to ensure every instance starts with the correct attributes. That's where the __init__ method comes in, which we'll cover in section 30.5. But first, let's learn about methods—functions that belong to a class.
30.4) Adding Instance Methods: Understanding self
Methods are functions defined inside a class that operate on instance data. They give your classes behavior, not just data.
Defining a Simple Method
Let's add a method to our Student class:
class Student:
def display_info(self):
print(f"{self.name} - GPA: {self.gpa}")
# Create an instance and add attributes
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
# Call the method
alice.display_info() # Output: Alice Johnson - GPA: 3.8The method display_info is defined inside the class using def, just like regular functions. The key difference is the first parameter: self.
Understanding self
The self parameter is how a method accesses the specific instance it's operating on. When you call alice.display_info(), Python automatically passes alice as the first argument to the method. Inside the method, self refers to alice, so self.name accesses alice.name and self.gpa accesses alice.gpa.
Here's what happens behind the scenes:
class Student:
def display_info(self):
print(f"{self.name} - GPA: {self.gpa}")
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
# These two calls are equivalent:
alice.display_info() # Normal way
Student.display_info(alice) # What Python actually does
# Both output: Alice Johnson - GPA: 3.8When you write alice.display_info(), Python translates it to Student.display_info(alice). The instance (alice) becomes the self parameter inside the method.
Why "self"?
The name self is a convention, not a keyword. You could technically use any name:
class Student:
def display_info(this): # Works, but don't do this
print(f"{this.name} - GPA: {this.gpa}")However, always use self. It's a universal Python convention that makes your code readable to other Python programmers. Using any other name will confuse readers and violate community standards.
Methods with Multiple Instances
The power of self becomes clear when you have multiple instances:
class Student:
def display_info(self):
print(f"{self.name} - GPA: {self.gpa}")
# Create two students
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
bob = Student()
bob.name = "Bob Smith"
bob.gpa = 3.5
# Same method, different data
alice.display_info() # Output: Alice Johnson - GPA: 3.8
bob.display_info() # Output: Bob Smith - GPA: 3.5When you call alice.display_info(), self is alice. When you call bob.display_info(), self is bob. The same method code works for any instance because self adapts to whichever instance called it.
Methods Can Take Additional Parameters
Methods can accept parameters beyond self:
class Student:
def update_gpa(self, new_gpa):
self.gpa = new_gpa
print(f"Updated {self.name}'s GPA to {self.gpa}")
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
alice.update_gpa(3.9) # Output: Updated Alice Johnson's GPA to 3.9
print(alice.gpa) # Output: 3.9When you call alice.update_gpa(3.9), Python passes alice as self and 3.9 as new_gpa. The method signature is def update_gpa(self, new_gpa), but you only pass one argument when calling it—Python handles self automatically.
Methods Can Return Values
Methods can return values just like regular functions:
class Student:
def is_honors(self):
return self.gpa >= 3.5
def get_status(self):
if self.is_honors():
return "Honors Student"
else:
return "Regular Student"
alice = Student()
alice.name = "Alice Johnson"
alice.gpa = 3.8
bob = Student()
bob.name = "Bob Smith"
bob.gpa = 3.2
print(alice.get_status()) # Output: Honors Student
print(bob.get_status()) # Output: Regular StudentNotice how get_status calls another method (is_honors) using self.is_honors(). Methods can call other methods on the same instance.
Methods vs Functions: When to Use Each
You might wonder when to use a method versus a standalone function. Here's the guideline:
Use a method when the operation:
- Needs access to instance data (
self.name,self.gpa, etc.) - Logically belongs to the type (it's something a Student does or is)
- Modifies the instance's state
Use a standalone function when the operation:
- Doesn't need instance data
- Works with multiple types
- Is a general utility
class Student:
# Method: needs instance data
def is_honors(self):
return self.gpa >= 3.5
# Function: general utility, works with any GPA value
def calculate_letter_grade(gpa):
if gpa >= 3.7:
return "A"
elif gpa >= 3.0:
return "B"
elif gpa >= 2.0:
return "C"
else:
return "D"
alice = Student()
alice.gpa = 3.8
# Use the method for instance-specific checks
print(alice.is_honors()) # Output: True
# Use the function for general calculations
print(calculate_letter_grade(alice.gpa)) # Output: A
print(calculate_letter_grade(2.5)) # Output: CCommon Method Patterns
Here are some common patterns you'll use frequently:
Getter methods (retrieve computed information):
class Student:
def get_full_info(self):
return f"{self.name} ({self.student_id}) - GPA: {self.gpa}"Setter methods (modify attributes with validation):
class Student:
def set_gpa(self, new_gpa):
if 0.0 <= new_gpa <= 4.0:
self.gpa = new_gpa
else:
print("Invalid GPA: must be between 0.0 and 4.0")Query methods (answer yes/no questions):
class Student:
def is_honors(self):
return self.gpa >= 3.5
def is_failing(self):
return self.gpa < 2.0Action methods (perform operations):
class Student:
def add_grade(self, grade):
self.grades.append(grade)
# Recalculate GPA based on all grades
self.gpa = sum(self.grades) / len(self.grades)30.5) Initializing Instances with __init__
Manually setting attributes after creating an instance is tedious and error-prone. The __init__ method solves this by letting you initialize instances with data when they're created.
The __init__ Method
The __init__ method (pronounced "dunder init" or "init") is a special method that Python calls automatically when you create a new instance:
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
# Create instances with initial data
alice = Student("Alice Johnson", "S12345", 3.8)
bob = Student("Bob Smith", "S12346", 3.5)
print(alice.name) # Output: Alice Johnson
print(bob.gpa) # Output: 3.5When you write Student("Alice Johnson", "S12345", 3.8), Python:
- Creates a new empty
Studentinstance - Calls
__init__with that instance asselfand your arguments - Returns the initialized instance
The __init__ method doesn't explicitly return a value—it modifies the instance in place by setting its attributes. If you try to return a value from __init__, Python will raise a TypeError.
class Student:
def __init__(self, name):
self.name = name
# Don't return anything from __init__
# return self # Wrong! TypeError: __init__() should return None, not 'Student'How __init__ Works
Let's break down what happens step by step:
class Student:
def __init__(self, name, student_id, gpa):
print(f"Initializing student: {name}")
self.name = name
self.student_id = student_id
self.gpa = gpa
print(f"Initialization complete")
alice = Student("Alice Johnson", "S12345", 3.8)
# Output:
# Initializing student: Alice Johnson
# Initialization complete
print(alice.name) # Output: Alice JohnsonThe parameters after self (name, student_id, gpa) become required arguments when creating an instance. If you don't provide them, Python raises a TypeError:
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
# student = Student() # TypeError: __init__() missing 3 required positional arguments
# student = Student("Alice") # TypeError: __init__() missing 2 required positional arguments
student = Student("Alice Johnson", "S12345", 3.8) # CorrectThis is much better than manual attribute assignment—Python enforces that every instance starts with the required data.
Default Parameter Values in __init__
You can use default parameter values in __init__, just like regular functions:
class Student:
def __init__(self, name, student_id, gpa=0.0):
self.name = name
self.student_id = student_id
self.gpa = gpa
# GPA is optional, defaults to 0.0
alice = Student("Alice Johnson", "S12345", 3.8)
bob = Student("Bob Smith", "S12346") # Uses default gpa=0.0
print(alice.gpa) # Output: 3.8
print(bob.gpa) # Output: 0.0This is useful for attributes that have sensible defaults but can be customized when needed.
Validation in __init__
You can validate input in __init__ to ensure instances start in a valid state:
class Student:
def __init__(self, name, student_id, gpa):
if not name:
print("Error: Name cannot be empty")
self.name = "Unknown"
else:
self.name = name
self.student_id = student_id
if 0.0 <= gpa <= 4.0:
self.gpa = gpa
else:
print(f"Warning: Invalid GPA {gpa}, setting to 0.0")
self.gpa = 0.0
alice = Student("Alice Johnson", "S12345", 3.8)
print(alice.gpa) # Output: 3.8
bob = Student("", "S12346", 5.0)
# Output:
# Error: Name cannot be empty
# Warning: Invalid GPA 5.0, setting to 0.0
print(bob.name) # Output: Unknown
print(bob.gpa) # Output: 0.0This ensures that even if someone passes invalid data, the instance ends up in a reasonable state.
30.6) String Representations with __str__ and __repr__
When you print an instance with print() or view it in the interactive shell, Python needs to convert it to a string. By default, you get something unhelpful:
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
alice = Student("Alice Johnson", "S12345", 3.8)
print(alice) # Output: <__main__.Student object at 0x...>The default output shows the class name and memory address, but nothing about Alice's actual data. You can customize this with the __str__ and __repr__ special methods.
The __str__ Method
The __str__ method defines how your instances are converted to strings by print() and str():
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
def __str__(self):
return f"{self.name} ({self.student_id}) - GPA: {self.gpa}"
alice = Student("Alice Johnson", "S12345", 3.8)
print(alice) # Output: Alice Johnson (S12345) - GPA: 3.8
print(str(alice)) # Output: Alice Johnson (S12345) - GPA: 3.8The __str__ method should return a string that's readable and informative for end users. Think of it as the "friendly" representation.
The __repr__ Method
The __repr__ method defines the "official" string representation of your instances, used by the REPL and repr():
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
def __repr__(self):
return f"Student('{self.name}', '{self.student_id}', {self.gpa})"
alice = Student("Alice Johnson", "S12345", 3.8)
print(repr(alice)) # Output: Student('Alice Johnson', 'S12345', 3.8)In the REPL:
>>> alice = Student("Alice Johnson", "S12345", 3.8)
>>> alice
Student('Alice Johnson', 'S12345', 3.8)The __repr__ method should return a string that looks like valid Python code to recreate the object. Think of it as the "developer" representation—it should be unambiguous and useful for debugging.
Using Both __str__ and __repr__
You can define both methods for different purposes:
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
def __str__(self):
# Friendly, readable format
return f"{self.name} - GPA: {self.gpa}"
def __repr__(self):
# Unambiguous, code-like format
return f"Student('{self.name}', '{self.student_id}', {self.gpa})"
alice = Student("Alice Johnson", "S12345", 3.8)
print(alice) # Uses __str__
# Output: Alice Johnson - GPA: 3.8
print(repr(alice)) # Uses __repr__
# Output: Student('Alice Johnson', 'S12345', 3.8)In the REPL:
>>> alice = Student("Alice Johnson", "S12345", 3.8)
>>> alice # Uses __repr__
Student('Alice Johnson', 'S12345', 3.8)
>>> print(alice) # Uses __str__
Alice Johnson - GPA: 3.8When to Define Which Method
Here's the guideline:
- Always define
__repr__: It's used by the REPL and debugging tools. If you only define one, define this one. - Define
__str__when you need a user-friendly format: If your class will be printed for end users, provide a readable__str__. - If you only define
__repr__: Python uses it forrepr(), andstr()falls back to using__repr__as well (soprint()also uses it). - If you only define
__str__:print()uses__str__, butrepr()and the REPL use the default__repr__(showing memory address). This is why defining__repr__is usually more important.
# Only __repr__ defined
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __repr__(self):
return f"Product('{self.name}', {self.price})"
item = Product("Laptop", 999.99)
print(item) # Uses __repr__ as fallback
# Output: Product('Laptop', 999.99)
print(repr(item)) # Uses __repr__
# Output: Product('Laptop', 999.99)String Representation in Collections
When instances are inside collections (lists, dicts, etc.), Python uses __repr__ to display them, not __str__:
class Student:
def __init__(self, name, gpa):
self.name = name
self.gpa = gpa
def __str__(self):
return f"{self.name}: {self.gpa}"
def __repr__(self):
return f"Student('{self.name}', {self.gpa})"
students = [
Student("Alice", 3.8),
Student("Bob", 3.5),
Student("Carol", 3.9)
]
# Printing the list uses __repr__ for each student
print(students)
# Output: [Student('Alice', 3.8), Student('Bob', 3.5), Student('Carol', 3.9)]
# Printing individual students uses __str__
for student in students:
print(student)
# Output:
# Alice: 3.8
# Bob: 3.5
# Carol: 3.9This is why __repr__ should be unambiguous—it helps you understand what's in your data structures during debugging. When you print a list, Python essentially calls repr() on each element to show the structure clearly.
30.7) Creating Multiple Independent Instances
One of the most powerful aspects of classes is that you can create many independent instances, each with its own data. Let's explore this in depth.
Each Instance Has Its Own Data
When you create multiple instances from the same class, each one maintains its own separate attributes:
class BankAccount:
def __init__(self, account_number, holder_name, balance=0.0):
self.account_number = account_number
self.holder_name = holder_name
self.balance = balance
def deposit(self, amount):
self.balance += amount
print(f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}")
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
print(f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}")
return True
else:
print(f"Insufficient funds. Balance: ${self.balance:.2f}")
return False
def __str__(self):
return f"{self.holder_name}'s account ({self.account_number}): ${self.balance:.2f}"
# Create three independent accounts
alice_account = BankAccount("ACC-001", "Alice Johnson", 1000.0)
bob_account = BankAccount("ACC-002", "Bob Smith", 500.0)
carol_account = BankAccount("ACC-003", "Carol Davis", 2000.0)
# Operations on one account don't affect others
alice_account.deposit(500)
# Output: Deposited $500.00. New balance: $1500.00
bob_account.withdraw(200)
# Output: Withdrew $200.00. New balance: $300.00
# Each account maintains its own balance
print(alice_account) # Output: Alice Johnson's account (ACC-001): $1500.00
print(bob_account) # Output: Bob Smith's account (ACC-002): $300.00
print(carol_account) # Output: Carol Davis's account (ACC-003): $2000.00This independence is fundamental to object-oriented programming. Each instance is a separate entity with its own state.
Instances in Collections
You can store instances in lists, dictionaries, or any other collection:
class Student:
def __init__(self, name, student_id, gpa):
self.name = name
self.student_id = student_id
self.gpa = gpa
def is_honors(self):
return self.gpa >= 3.5
def __repr__(self):
return f"Student('{self.name}', '{self.student_id}', {self.gpa})"
# Create a list of students
students = [
Student("Alice Johnson", "S12345", 3.8),
Student("Bob Smith", "S12346", 3.2),
Student("Carol Davis", "S12347", 3.9),
Student("David Lee", "S12348", 3.4)
]
# Find all honors students
honors_students = []
for student in students:
if student.is_honors():
honors_students.append(student)
print("Honors students:")
for student in honors_students:
print(f" {student.name}: {student.gpa}")
# Output:
# Honors students:
# Alice Johnson: 3.8
# Carol Davis: 3.9
# Calculate average GPA
total_gpa = sum(student.gpa for student in students)
average_gpa = total_gpa / len(students)
print(f"Average GPA: {average_gpa:.2f}") # Output: Average GPA: 3.58This is a common pattern: create multiple instances, store them in a collection, then process them with loops and comprehensions.
Instances Can Reference Other Instances
Instances can have attributes that reference other instances, creating relationships between objects:
class Course:
def __init__(self, course_code, course_name):
self.course_code = course_code
self.course_name = course_name
def __str__(self):
return f"{self.course_code}: {self.course_name}"
class Student:
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
self.courses = [] # List of Course instances
def enroll(self, course):
self.courses.append(course)
print(f"{self.name} enrolled in {course.course_name}")
def list_courses(self):
print(f"{self.name}'s courses:")
for course in self.courses:
print(f" {course}")
# Create courses
python_course = Course("CS101", "Introduction to Python")
data_course = Course("CS102", "Data Structures")
web_course = Course("CS103", "Web Development")
# Create students and enroll them in courses
alice = Student("Alice Johnson", "S12345")
alice.enroll(python_course)
alice.enroll(data_course)
# Output:
# Alice Johnson enrolled in Introduction to Python
# Alice Johnson enrolled in Data Structures
bob = Student("Bob Smith", "S12346")
bob.enroll(python_course)
bob.enroll(web_course)
# Output:
# Bob Smith enrolled in Introduction to Python
# Bob Smith enrolled in Web Development
# List each student's courses
alice.list_courses()
# Output:
# Alice Johnson's courses:
# CS101: Introduction to Python
# CS102: Data Structures
bob.list_courses()
# Output:
# Bob Smith's courses:
# CS101: Introduction to Python
# CS103: Web DevelopmentNotice that both Alice and Bob are enrolled in python_course—they're referencing the same Course instance. This models the real-world relationship where multiple students can take the same course.
Instance Identity and Equality
Each instance is a unique object, even if it has the same data as another instance:
class Student:
def __init__(self, name, gpa):
self.name = name
self.gpa = gpa
alice1 = Student("Alice", 3.8)
alice2 = Student("Alice", 3.8)
# Different objects, even with identical data
print(alice1 is alice2) # Output: False
print(id(alice1) == id(alice2)) # Output: FalseBy default, == also checks identity (whether they're the same object), not whether they have the same data. In Chapter 31, we'll learn how to customize equality comparison with the __eq__ special method.
This chapter has introduced you to the fundamentals of object-oriented programming in Python. You've learned how to define classes, create instances, add methods, initialize instances with __init__, control string representations, and work with multiple independent instances. These concepts form the foundation for more advanced OOP features we'll explore in Chapters 31 and 32.