# Advanced OOP

Course: [Python](https://www.devtutorials.ai/learn/python) · Section: Object Oriented Programming
Source: https://www.devtutorials.ai/learn/python/oop/advanced-oop

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

```python runnable title="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:

```python runnable title="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:

```python runnable title="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.

```python runnable expect-error title="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:

```python runnable title="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

<div className="tscroll">

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

</div>

```python runnable title="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:

```python runnable title="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:

```python runnable title="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.

```python runnable title="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:

```python runnable title="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:

```python runnable expect-error title="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.

```python runnable title="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:

```python runnable expect-error title="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:

```python runnable title="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

<div className="tscroll">

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

</div>

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 runnable title="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.

```python runnable title="__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.

```python runnable title="__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:

```python runnable title="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.

```python runnable title="__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:

```python runnable title="__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.

```python runnable title="__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__`.

```python runnable expect-error title="__eq__ without __hash__"
class Student:
    def __init__(self, name):
        self.name = name

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

{Student("Asha")}
```

```python runnable title="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.

```python runnable title="__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:

```python runnable title="__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.

```python runnable title="__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.

```python runnable title="__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:

```python runnable expect-error title="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.

```python runnable title="__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:

```python runnable title="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:

```python runnable title="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:

<div className="tscroll">

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

</div>

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.

<div className="tscroll">

| 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)` |

</div>

## 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**
