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
@dataclasswrites 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
| Use | Why |
|---|---|
| A helper nobody else needs | it says "this belongs to me" |
| Grouping settings | Model.Meta is a common pattern |
| A small record type | keeps the file tidy |
| Avoiding a name clash | School.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
| Benefit | What it saves |
|---|---|
__init__ written for you | one line per attribute |
Readable __repr__ | no more <object at 0x...> |
Sensible __eq__ | compares values, not identity |
order=True | four comparison methods |
frozen=True | immutability in one word |
| Mutable defaults refused | a 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:
| Method | Called by | Must return |
|---|---|---|
__init__ | creating the object | nothing |
__str__ | print(), str() | text, for people |
__repr__ | repr(), the shell, lists | text, for programmers |
__eq__ | == | True, False or NotImplemented |
__len__ | len(), truth tests | a whole number, not negative |
__call__ | obj(...) | anything |
__contains__ | in | True 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.
| Mistake | What you see | Why it happens | Fix |
|---|---|---|---|
| Expecting an inner class to see the outer object | AttributeError on an outer attribute | The inner class has no link to the outer one | Pass the outer object in |
| A mutable default in a data class | ValueError: mutable default ... is not allowed | Python catches this one | field(default_factory=list) |
| A data class field with no annotation | The field is silently ignored | @dataclass reads the annotations | Annotate every field |
| Expecting annotations to be checked | Any type is accepted | Python never enforces annotations | Use a checker such as mypy |
Only __str__, then printing a list | <object at 0x...> inside the list | Lists use __repr__ | Define __repr__ as well |
__eq__ without __hash__ | TypeError: unhashable type | Defining __eq__ removes the default hash | Add __hash__, or use frozen=True |
__len__ returning a decimal | TypeError: 'float' object cannot be interpreted as an integer | It must be a whole number | Return an int |
in on a class with no __contains__ or __iter__ | TypeError: argument of type ... is not iterable | Python has nothing to fall back on | Define one of them |
__iter__ returning a list | TypeError: iter() returned non-iterator | It must return an iterator | return iter(self.items) |
Sorting a data class without order=True | TypeError: '<' not supported | Only __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. @dataclasswrites__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=Truemakes the objects sortable;frozen=Truemakes them immutable and hashable.- Magic methods are called by Python, not by you:
__init__on creation,__str__forprint(),__len__forlen(). __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 withfor,list(),sum()andmax()at once.
Next: Lists