Python & AI Tutorials Logo
Python Programming

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:

python
# 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:

  1. No validation: Nothing prevents you from setting gpa to an invalid value like -5.0 or "excellent"
  2. No related behavior: Operations like calculating honors status or formatting student information are separate functions scattered throughout your code
  3. 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:

python
# 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: True

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

Student Class
Blueprint

alice instance
Name: Alice Johnson
ID: S12345
GPA: 3.8

bob instance
Name: Bob Smith
ID: S12346
GPA: 3.5

carol instance
Name: Carol Davis
ID: S12347
GPA: 3.9

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:

  1. Defining classes with the class keyword
  2. Creating instances and accessing their attributes
  3. Adding methods that operate on instance data
  4. Understanding self and how methods access instance data
  5. Initializing instances with the __init__ method
  6. Controlling string representations with __str__ and __repr__
  7. 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:

python
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:

python
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 constants

This 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:

python
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: True

Each 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:

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

  2. Practical use: Even empty classes can be useful as markers or placeholders. For example, you might define custom exception types:

python
class InvalidGradeError:
    pass
 
class StudentNotFoundError:
    pass
 
# These empty classes serve as distinct error types

However, 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:

python
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.8

The 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:

python
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:

python
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.com

This 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:

python
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:

python
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)  # AttributeError

This 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:

python
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.8

The 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:

python
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.8

When 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:

python
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:

python
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.5

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

alice.display_info

self = alice

bob.display_info

self = bob

Access alice.name
alice.gpa

Access bob.name
bob.gpa

Methods Can Take Additional Parameters

Methods can accept parameters beyond self:

python
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.9

When 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:

python
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 Student

Notice 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
python
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: C

Common Method Patterns

Here are some common patterns you'll use frequently:

Getter methods (retrieve computed information):

python
class Student:
    def get_full_info(self):
        return f"{self.name} ({self.student_id}) - GPA: {self.gpa}"

Setter methods (modify attributes with validation):

python
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):

python
class Student:
    def is_honors(self):
        return self.gpa >= 3.5
    
    def is_failing(self):
        return self.gpa < 2.0

Action methods (perform operations):

python
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:

python
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.5

When you write Student("Alice Johnson", "S12345", 3.8), Python:

  1. Creates a new empty Student instance
  2. Calls __init__ with that instance as self and your arguments
  3. 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.

python
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:

python
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 Johnson

The parameters after self (name, student_id, gpa) become required arguments when creating an instance. If you don't provide them, Python raises a TypeError:

python
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)  # Correct

This 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:

python
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.0

This 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:

python
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.0

This 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:

python
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():

python
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.8

The __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():

python
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:

python
>>> 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:

python
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:

python
>>> alice = Student("Alice Johnson", "S12345", 3.8)
>>> alice  # Uses __repr__
Student('Alice Johnson', 'S12345', 3.8)
>>> print(alice)  # Uses __str__
Alice Johnson - GPA: 3.8

When 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 for repr(), and str() falls back to using __repr__ as well (so print() also uses it).
  • If you only define __str__: print() uses __str__, but repr() and the REPL use the default __repr__ (showing memory address). This is why defining __repr__ is usually more important.
python
# 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__:

python
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.9

This 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:

python
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.00

This 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:

python
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.58

This 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:

python
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 Development

Notice 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:

python
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: False

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

© 2025. Primesoft Co., Ltd.
support@primesoft.ai