Advanced OOP

By the Syncfusion DevTutorials team · Last updated

Inner classes, data classes with @dataclass, and the magic methods that make your objects work with print(), len(), in and for.

You can build a working program with what the last five lessons covered.

This one is about the parts that make classes pleasant to use: a class that lives inside another because it belongs there, a way to write a data-holding class in three lines instead of thirty, and the methods Python calls behind your back so your objects work with print(), len(), in and for.

Here are the same two classes, one written by hand and one written with a decorator:

The same class, before and after

class PlainStudent:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    marks: int

print(PlainStudent("Asha", 78))
print(Student("Asha", 78))
print(Student("Asha", 78) == Student("Asha", 78))

One decorator, and the second class prints readably and compares sensibly.

Magic methods are the methods Python calls for you, and a data class writes several of them automatically.

What You Will Learn

In this lesson, you will learn:

  • How to put a class inside another class, and when that helps
  • How @dataclass writes the boring parts of a data-holding class for you
  • What magic methods are, and the nine worth knowing early

By the end of this lesson, you will be able to place a class inside another class where it belongs, turn a data-holding class into a few annotated lines with @dataclass, and teach your own objects to work with print(), len(), in and for by writing magic methods.

Inner Classes

What Are Inner Classes?

An inner class is a class defined inside another class. It is a way of saying "this only makes sense here".

For example, a Student class written inside a School class says that this particular kind of student only exists as part of a school. Next you will see how to create one and reach it from outside.

Creating Inner Classes

You create an inner class the same way as any other class, but written inside the body of the outer class. You reach it through the outer class's name:

A class inside a class

class School:
    def __init__(self, name):
        self.name = name
        self.students = []

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

        def __repr__(self):
            return f"Student({self.name}, {self.marks})"

    def enrol(self, name, marks):
        student = School.Student(name, marks)
        self.students.append(student)
        return student

school = School("Green Valley")

school.enrol("Asha", 78)
school.enrol("Ravi", 85)

print(school.students)

Student is written inside School and reached as School.Student.

Accessing Inner Classes

The inner class is an attribute of the outer one:

Reaching the inner class

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

asha = School.Student("Asha")

print(asha.name)
print(School.Student)
print(School.Student.__qualname__)

__qualname__ shows the full path, which is how you tell an inner class from a top-level one in error messages.

One thing surprises people: the inner class knows nothing about the outer one. It is not like an inner class in Java.

The inner class cannot see the outer object

class School:
    def __init__(self, name):
        self.name = name

    class Student:
        def describe(self):
            return f"a student at {self.name}"

school = School("Green Valley")
student = School.Student()

print(student.describe())

self inside Student is the student, and a student has no name. If the inner object needs the outer one, you must hand it over:

Passing the outer object in

class School:
    def __init__(self, name):
        self.name = name
        self.students = []

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

        def describe(self):
            return f"{self.name} studies at {self.school.name}"

    def enrol(self, name):
        student = School.Student(self, name)
        self.students.append(student)
        return student

school = School("Green Valley")
asha = school.enrol("Asha")

print(asha.describe())

Use Cases of Inner Classes

UseWhy
A helper nobody else needsit says "this belongs to me"
Grouping settingsModel.Meta is a common pattern
A small record typekeeps the file tidy
Avoiding a name clashSchool.Student and Club.Student can both exist

Two Students that never clash

class School:
    class Student:
        def __init__(self, marks):
            self.marks = marks

        def __repr__(self):
            return f"SchoolStudent({self.marks})"

class MusicClub:
    class Student:
        def __init__(self, instrument):
            self.instrument = instrument

        def __repr__(self):
            return f"ClubStudent({self.instrument})"

print(School.Student(78))
print(MusicClub.Student("violin"))

Be honest about when this earns its place. A separate top-level class, or a module, is usually clearer. Reach for an inner class when the inner thing is genuinely meaningless outside the outer one.

Data Classes

The second tool is about removing repetitive writing. Many classes exist only to hold values, and writing their boilerplate by hand is wasted effort.

What Are Data Classes?

A data class is a class whose job is to hold values. Python writes the repetitive parts for you.

Written by hand, a small record takes a lot of lines:

A record class written out in full

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

    def __repr__(self):
        return f"Student(name={self.name!r}, marks={self.marks!r})"

    def __eq__(self, other):
        if not isinstance(other, Student):
            return NotImplemented
        return (self.name, self.marks) == (other.name, other.marks)

asha = Student("Asha", 78)

print(asha)
print(asha == Student("Asha", 78))

Three methods, and none of them says anything interesting. Every record class needs the same three.

The @dataclass Decorator

The decorator writes those three methods for you, based on the annotated fields:

The same thing, written by Python

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    marks: int = 0

asha = Student("Asha", 78)

print(asha)
print(asha == Student("Asha", 78))
print(asha.name, asha.marks)
print(Student("Ravi"))

@dataclass read the annotated names and wrote __init__, __repr__ and __eq__. The annotations are required — that is how it knows which attributes there are — but Python still does not check the types.

Annotations are still not enforced

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    marks: int

print(Student("Asha", "not a number"))

Creating Data Classes

A data class can have methods, defaults and everything else a normal class has:

A data class with behaviour

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    marks: int = 0
    section: str = "7A"

    def grade(self):
        if self.marks >= 80:
            return "A"
        return "B" if self.marks >= 60 else "C"

    def __str__(self):
        return f"{self.name} ({self.section}): {self.marks} - {self.grade()}"

asha = Student("Asha", 78)

print(asha)
print(repr(asha))
print(asha.grade())

A mutable default needs field(default_factory=...), and Python refuses the dangerous version outright:

A mutable default is refused

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    marks: list = []

ValueError: mutable default <class 'list'> for field marks is not allowed — the same shared-list trap from the functions and classes lessons, except this time Python catches it.

The right way to default a list

from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    marks: list = field(default_factory=list)

asha = Student("Asha")
ravi = Student("Ravi")

asha.marks.append(78)

print(asha)
print(ravi)

frozen=True makes the objects immutable, which is useful for values that should never change:

A frozen data class

from dataclasses import dataclass

@dataclass(frozen=True)
class Term:
    year: int
    number: int

term = Term(2026, 1)

print(term)

term.number = 2

order=True writes the comparison methods, so the objects can be sorted:

Sortable with one argument

from dataclasses import dataclass

@dataclass(order=True)
class Student:
    marks: int
    name: str

students = [Student(78, "Asha"), Student(85, "Ravi"), Student(60, "Meera")]

print(sorted(students))
print(max(students))

Comparison uses the fields in the order they are written, so marks first means sorting by marks. Put the field you want to sort by first.

Benefits of Data Classes

BenefitWhat it saves
__init__ written for youone line per attribute
Readable __repr__no more <object at 0x...>
Sensible __eq__compares values, not identity
order=Truefour comparison methods
frozen=Trueimmutability in one word
Mutable defaults refuseda whole class of quiet bug

Use a data class when the class is mostly data. When it is mostly behaviour, a normal class says so more clearly.

Magic Methods

Data classes write a few magic methods for you. Now you will learn what those methods are, and how to write them yourself.

What Are Magic Methods?

A magic method has two underscores on each side of its name. Python calls it for you when something happens to your object.

You have met several already: __init__ when an object is created, __add__ for +, __eq__ for ==.

Dunder Methods

They are usually called dunder methods, short for "double underscore". __init__ is said aloud as "dunder init".

The pattern is always the same: you write the method, Python calls it when the matching thing happens.

Python calling your methods for you

class Noisy:
    def __init__(self):
        print("__init__ ran because an object was created")

    def __str__(self):
        print("__str__ ran because print() needed text")
        return "a Noisy object"

    def __len__(self):
        print("__len__ ran because len() was called")
        return 3

thing = Noisy()
print(thing)
print(len(thing))

Not one of those was called by name.

__init__()

Runs when an object is created, to set its starting state.

__init__

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

asha = Student("Asha", 78)

print(asha.name, asha.marks)

__str__()

What print() and str() use. It is for people, so it should be readable.

__str__

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

    def __str__(self):
        return f"{self.name} scored {self.marks}"

asha = Student("Asha", 78)

print(asha)
print(str(asha))

Without it you get the default, which tells a reader nothing:

Without __str__

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

print(Student("Asha"))

__repr__()

What the shell shows, and what repr() gives. It is for programmers, and should ideally look like the code that would rebuild the object.

__str__ and __repr__ side by side

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

    def __str__(self):
        return f"{self.name} scored {self.marks}"

    def __repr__(self):
        return f"Student({self.name!r}, {self.marks})"

asha = Student("Asha", 78)

print(asha)
print(repr(asha))
print([asha])

The last line matters: a list shows its items using __repr__, not __str__. A class with only __str__ still prints as <object at 0x...> inside a list.

If you write only one, write __repr__ — print() falls back to it:

__repr__ alone covers both

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

    def __repr__(self):
        return f"Student({self.name!r})"

asha = Student("Asha")

print(asha)
print([asha])

!r inside an f-string means "use repr() for this value", which is what puts the quote marks around the name.

__eq__()

What == uses. Without it, two identical records are not equal.

__eq__

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

    def __eq__(self, other):
        if not isinstance(other, Student):
            return NotImplemented
        return self.name == other.name and self.marks == other.marks

a = Student("Asha", 78)
b = Student("Asha", 78)

print(a == b)
print(a != b)
print(a == "Asha")

One side effect is worth knowing: defining __eq__ makes a class unusable in a set or as a dictionary key, unless you also define __hash__.

__eq__ without __hash__

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

    def __eq__(self, other):
        return self.name == other.name

{Student("Asha")}

Adding __hash__ back

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

    def __eq__(self, other):
        return self.name == other.name

    def __hash__(self):
        return hash(self.name)

print({Student("Asha"), Student("Asha"), Student("Ravi")})
print(len({Student("Asha"), Student("Asha")}))

Two equal students counted as one, which is what a set is for. (A @dataclass with frozen=True gets __hash__ for free.)

Defining __eq__ quietly removes the default __hash__, so the class stops working in a set or as a dictionary key. Python does that on purpose: two objects that are equal must hash the same, and it cannot work out how. Add __hash__ yourself, or let @dataclass(frozen=True) supply one.

__len__()

What len() uses. It must return a whole number that is not negative.

__len__

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __len__(self):
        return len(self.marks)

sheet = MarkSheet([78, 85, 60])

print(len(sheet))

It also decides whether your object counts as true or false, which is a free and useful side effect:

__len__ decides true or false too

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __len__(self):
        return len(self.marks)

full = MarkSheet([78, 85])
empty = MarkSheet([])

print(bool(full), bool(empty))

if not empty:
    print("The empty sheet counts as false")

__call__()

Lets an object be used like a function.

__call__ makes an object callable

class Grader:
    def __init__(self, pass_mark):
        self.pass_mark = pass_mark

    def __call__(self, marks):
        return "Pass" if marks >= self.pass_mark else "Fail"

school_grader = Grader(35)
club_grader = Grader(60)

print(school_grader(78))
print(club_grader(50))
print(callable(school_grader))

Each object is a function that remembers its own pass mark. Use it when you want a function with settings attached.

__contains__()

What in uses.

__contains__

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __contains__(self, value):
        return value in self.marks

sheet = MarkSheet([78, 85, 60])

print(78 in sheet)
print(99 in sheet)
print(99 not in sheet)

Without it, Python falls back to looping with __iter__, and without that too, in fails:

in with neither method

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

print(78 in MarkSheet([78, 85]))

__iter__()

What for uses. It must return an iterator — something with a __next__ method. The simplest way is to hand back the iterator of a collection you already hold.

__iter__

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __iter__(self):
        return iter(self.marks)

sheet = MarkSheet([78, 85, 60])

for mark in sheet:
    print(mark)

print(list(sheet))
print(max(sheet))
print(sum(sheet))

One method, and the object now works with for, list(), max(), sum() and everything else that loops.

A generator works too, and lets you decide what gets produced:

An iterator that filters as it goes

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __iter__(self):
        for mark in self.marks:
            if mark >= 35:
                yield mark

sheet = MarkSheet([78, 20, 85, 12])

print(list(sheet))

Everything together

Here is one class with all nine of the methods from this lesson working together:

One class, nine magic methods

class MarkSheet:
    def __init__(self, name, marks):
        self.name = name
        self.marks = list(marks)

    def __str__(self):
        return f"{self.name}: {len(self.marks)} marks"

    def __repr__(self):
        return f"MarkSheet({self.name!r}, {self.marks})"

    def __eq__(self, other):
        return self.name == other.name and self.marks == other.marks

    def __len__(self):
        return len(self.marks)

    def __contains__(self, mark):
        return mark in self.marks

    def __iter__(self):
        return iter(self.marks)

    def __call__(self, mark):
        self.marks.append(mark)
        return self

    def __add__(self, other):
        return MarkSheet(self.name, self.marks + other.marks)

sheet = MarkSheet("Asha", [78, 85])

print(str(sheet))
print(repr(sheet))
print(len(sheet))
print(78 in sheet)
print(list(sheet))
print(sum(sheet))

sheet(60)
print(repr(sheet))

print(repr(sheet + MarkSheet("Asha", [92])))
print(sheet == MarkSheet("Asha", [78, 85, 60]))

Each method covers one job, so you can add only the ones you need. This table shows what each one is called by and what it must return:

MethodCalled byMust return
__init__creating the objectnothing
__str__print(), str()text, for people
__repr__repr(), the shell, liststext, for programmers
__eq__==True, False or NotImplemented
__len__len(), truth testsa whole number, not negative
__call__obj(...)anything
__contains__inTrue or False
__iter__for, list(), sum()an iterator

Add a __repr__ to every class you write. It costs one line and turns every error message, log line and debugging print from <object at 0x7f...> into something you can read.

Common mistakes

Each row is one mistake. The middle column is what you will actually see on screen.

MistakeWhat you seeWhy it happensFix
Expecting an inner class to see the outer objectAttributeError on an outer attributeThe inner class has no link to the outer onePass the outer object in
A mutable default in a data classValueError: mutable default ... is not allowedPython catches this onefield(default_factory=list)
A data class field with no annotationThe field is silently ignored@dataclass reads the annotationsAnnotate every field
Expecting annotations to be checkedAny type is acceptedPython never enforces annotationsUse a checker such as mypy
Only __str__, then printing a list<object at 0x...> inside the listLists use __repr__Define __repr__ as well
__eq__ without __hash__TypeError: unhashable typeDefining __eq__ removes the default hashAdd __hash__, or use frozen=True
__len__ returning a decimalTypeError: 'float' object cannot be interpreted as an integerIt must be a whole numberReturn an int
in on a class with no __contains__ or __iter__TypeError: argument of type ... is not iterablePython has nothing to fall back onDefine one of them
__iter__ returning a listTypeError: iter() returned non-iteratorIt must return an iteratorreturn iter(self.items)
Sorting a data class without order=TrueTypeError: '<' not supportedOnly __eq__ is written by default@dataclass(order=True)

What to Remember

  • An inner class lives inside another and is reached as Outer.Inner. It has no link to the outer object — pass that in if it is needed.
  • @dataclass writes __init__, __repr__ and __eq__ from the annotated fields.
  • A field needs an annotation to count, and the annotation is still not enforced.
  • A mutable default in a data class is refused — use field(default_factory=list).
  • order=True makes the objects sortable; frozen=True makes them immutable and hashable.
  • Magic methods are called by Python, not by you: __init__ on creation, __str__ for print(), __len__ for len().
  • __str__ is for people; __repr__ is for programmers, and lists always use __repr__.
  • If you write only one, write __repr__ — print() falls back to it.
  • Defining __eq__ removes the default __hash__, so sets and dictionary keys stop working until you add one.
  • __len__ also decides whether the object counts as true or false.
  • __iter__ is the highest-value one: it makes an object work with for, list(), sum() and max() at once.

Next: Lists