Inner classes

Inner Classes in Python

A class can be defined inside another class. Such a class is called an inner class, or nested class.

Suppose we are developing a student management application. Each student has a name and an address. The address itself contains details such as the city and PIN code.

We can represent the student using a Student class and group the address-related data inside an Address class.

class Student:
    class Address:
        pass

Here, Student is the outer class, and Address is the inner class.

The nested definition groups Address under Student. However, creating a Student object does not automatically create an Address object. We create each object explicitly.

Creating an Inner Class Object

Let us begin with a small example:

class Student:
    class Address:
        def display(self):
            print("This is the student's address.")


address1 = Student.Address()
address1.display()

Output:

This is the student's address.

In this statement:

address1 = Student.Address()

Python accesses the Address class through Student and then creates an instance of it.

We do not need a Student object first. The inner class is accessible through the outer class itself.

Expression Meaning
Student The outer class
Student.Address The inner class
Student.Address() Creates an Address object
address1.display() Calls a method on that object

Adding Data to the Inner Class

We can define an initializer and instance variables inside an inner class, just as we do in any other class.

class Student:
    class Address:
        def __init__(self, city, pin_code):
            self.city = city
            self.pin_code = pin_code

        def display(self):
            print("City:", self.city)
            print("PIN Code:", self.pin_code)


address1 = Student.Address("Bengaluru", "560036")

address1.display()

Output:

City: Bengaluru
PIN Code: 560036

Inside Address, self refers to the Address object receiving the method call.

It does not refer to a Student object.

The PIN code is stored as a string because it is an identifier, not a number used for arithmetic.

Connecting the Student and Address Objects

Now let us give each student an address.

class Student:
    def __init__(self, name, city, pin_code):
        self.name = name
        self.address = Student.Address(city, pin_code)

    def display(self):
        print("Name:", self.name)
        self.address.display()

    class Address:
        def __init__(self, city, pin_code):
            self.city = city
            self.pin_code = pin_code

        def display(self):
            print("City:", self.city)
            print("PIN Code:", self.pin_code)


student1 = Student("Rahul", "Bengaluru", "560036")

student1.display()

Output:

Name: Rahul
City: Bengaluru
PIN Code: 560036

Focus on this statement inside the Student initializer:

self.address = Student.Address(city, pin_code)

It performs two actions:

  1. Creates an Address object using the supplied city and PIN code.

  2. Stores a reference to that object in the student’s address attribute.

The student now has two instance attributes:

Attribute Holds
student1.name The string "Rahul"
student1.address A reference to an Address object

When the Student method executes:

self.address.display()

it calls display() on the associated Address object.

This relationship is also an example of composition: a Student object contains a reference to an Address object.

Nesting organizes the class definitions. The assignment to self.address creates the relationship between the objects.

Accessing the Inner Object’s Attributes

We can access address details through the student:

print(student1.address.city)
print(student1.address.pin_code)

Output:

Bengaluru
560036

Read this expression from left to right:

student1.address.city
  • student1 refers to the Student object.

  • student1.address refers to its Address object.

  • .city accesses the city stored in that Address object.

We can update an address attribute in the same way:

student1.address.city = "Mysuru"
student1.address.pin_code = "570001"

student1.display()

Output:

Name: Rahul
City: Mysuru
PIN Code: 570001

Creating Multiple Students

Using the same class definition, we can create students with different addresses:

student1 = Student("Rahul", "Bengaluru", "560036")
student2 = Student("Anjali", "Hyderabad", "500001")

student1.display()
print()
student2.display()

Output:

Name: Rahul
City: Bengaluru
PIN Code: 560036

Name: Anjali
City: Hyderabad
PIN Code: 500001

Each call to the Student initializer creates a new Address object.

print(student1.address is student2.address)

Output:

False

Changing the first student’s address therefore does not change the second student’s address:

student1.address.city = "Mysuru"

print(student1.address.city)
print(student2.address.city)

Output:

Mysuru
Hyderabad

The separate addresses result from creating a new Address instance for each student. Nesting alone does not guarantee separate objects.

Understanding self in Both Classes

Both classes use the parameter name self, but it refers to a different object in each method call.

class Student:
    def __init__(self, name, city):
        self.name = name
        self.address = Student.Address(city)

    class Address:
        def __init__(self, city):
            self.city = city
Method self refers to
Student.__init__() The Student instance being initialized
Address.__init__() The Address instance being initialized

Inside Address, writing:

self.name

would look for name on the Address object. It would not automatically access the student’s name.

An inner class instance has no automatic reference to an outer class instance.

If it needs the outer object, we must pass that object explicitly.

Passing the Outer Object to the Inner Object

Consider an Order that has an associated Invoice. The invoice needs to read the order number and total.

class Order:
    def __init__(self, order_number, total):
        self.order_number = order_number
        self.total = total
        self.invoice = Order.Invoice(self)

    class Invoice:
        def __init__(self, order):
            self.order = order

        def display(self):
            print("Order Number:", self.order.order_number)
            print("Total:", self.order.total)


order1 = Order("ORD101", 1500)

order1.invoice.display()

Output:

Order Number: ORD101
Total: 1500

Inside the Order initializer:

self.invoice = Order.Invoice(self)

the self passed inside the parentheses is the current Order object.

Inside the Invoice initializer:

def __init__(self, order):
    self.order = order

the parameters have different roles:

Parameter Refers to
self The new Invoice object
order The Order object supplied by the caller

The invoice stores that reference in self.order. It can then access:

self.order.order_number
self.order.total

If the order’s total changes, the invoice reads the updated value:

order1.total = 1800

order1.invoice.display()

Output:

Order Number: ORD101
Total: 1800

For this example, the invoice reads the live order data rather than storing a separate copy.

Accessing an Outer Class Variable

An inner class can access an outer class variable by using the outer class name.

class College:
    college_name = "ABC College"

    class Department:
        def __init__(self, department_name):
            self.department_name = department_name

        def display(self):
            print("College:", College.college_name)
            print("Department:", self.department_name)


department1 = College.Department("Computer Science")

department1.display()

Output:

College: ABC College
Department: Computer Science

The explicit expression:

College.college_name

accesses the outer class attribute.

Writing just college_name inside Department.display() does not automatically search the surrounding College class. Similarly, self.college_name would look on the Department instance and its class hierarchy.

Nesting Does Not Mean Inheritance

These definitions describe different relationships:

class Student:
    class Address:
        pass

Here, Address is nested inside Student.

class ResearchStudent(Student):
    pass

Here, ResearchStudent inherits from Student.

An inner class does not automatically inherit the outer class’s methods or attributes. Nesting also does not make the inner class private: it remains accessible as Student.Address.

Choosing between an Inner Class and a Separate Class

An inner class can be useful when its name and purpose are closely tied to one outer class.

For example:

Order.Invoice

clearly groups the invoice class under Order.

However, if addresses are used by students, employees, customers, and suppliers, a separate Address class may be more convenient:

class Address:
    def __init__(self, city, pin_code):
        self.city = city
        self.pin_code = pin_code


class Student:
    def __init__(self, name, address):
        self.name = name
        self.address = address


address1 = Address("Bengaluru", "560036")
student1 = Student("Rahul", address1)

print(student1.name)
print(student1.address.city)

Output:

Rahul
Bengaluru

The Student object still has an Address object. Composition works with both nested and separate class definitions.

Choose nesting when grouping the class under the outer class makes the design clearer. Use a separate class when it has a broader role in the application.

Practice

Create a Computer class with an inner class named Processor.

The Computer class should:

  • Store the computer’s brand.

  • Create a Processor object and store it in self.processor.

  • Provide a display_details() method.

The Processor class should:

  • Store model and cores.

  • Provide a display() method.

Test your program with:

computer1 = Computer("Dell", "Intel Core i5", 6)

computer1.display_details()

Expected output:

Brand: Dell
Processor: Intel Core i5
Cores: 6

Then create a second computer with different processor details. Update the first computer’s core count through:

computer1.processor.cores = 8

Display both computers and verify that only the first computer’s processor details changed.

Previous Topic accessing object attributes