# Classes and OOP in Python: __init__, self, methods, inheritance and dataclass

URL: https://skillaria.top/en/guides/python/python-classes-oop-dataclass

OOP in Python is built on classes: a class describes fields and methods, and objects (instances) are created by calling the class; __init__ fills in the fields, and self refers to the current object. Inheritance lets you reuse a parent's behaviour and override some of its methods, and the @dataclass decorator generates __init__, __repr__ and __eq__ for classes that mainly store data.

A class in Python is a blueprint: it describes what data an object stores and what can be done with it. An object (an instance) is a specific item made from that blueprint. Python OOP rests on three things: classes with methods, inheritance and special methods such as `__init__` and `__repr__`. Let us go through everything in order using one running example — tracking expenses.

## Why use classes when there are dictionaries

You can store data in a dictionary: `{"title": "Coffee", "amount": 250}`. The problem is that a dictionary does not know which keys are correct. A typo like `expense.get("amout", 0)` raises no error — it returns zero, and the report quietly starts lying.

An object of a class has its fields declared in advance. Accessing a non-existent attribute immediately fails with `AttributeError`, and modern Python even suggests the correct name. In addition, a class gathers the data and the actions on it in one place: an expense can compute its amount with tax by itself, and a report computes its total by itself.

## How to create a class in Python

A class is declared with the word `class`; by PEP 8 convention, class names are written in CamelCase. The simplest class example:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

coffee = Expense("Coffee", 250)
taxi = Expense("Taxi", 300)
print(coffee.title, coffee.amount)   # Coffee 250
```

The call `Expense("Coffee", 250)` creates a new instance of the class. `coffee` and `taxi` are two different objects of the same type, each with its own field values. You can check membership with `isinstance(coffee, Expense)` — it returns `True`.

### The __init__ constructor: initialising a class

`__init__` is often called the class constructor, though "initialiser" is more accurate. The object itself is created by the `__new__` method, and `__init__` is called right after that and places the passed arguments into the fields. It does not need to return anything (strictly speaking, it must return `None`).

Inside `__init__` it is useful to validate input data, so that obviously incorrect objects never appear in the program:

```
class Expense:
    def __init__(self, title, amount):
        if amount < 0:
            raise ValueError("The amount cannot be negative")
        self.title = title
        self.amount = amount
```

### What self means in Python

`self` is a reference to the very object the method is currently working with. When you write `coffee.with_tax()`, Python effectively calls `Expense.with_tax(coffee)` and passes the object as the first argument. That is why `self` comes first in the method declaration, and why it is not written at the call site.

`self` is not a keyword but a convention: technically the parameter can be called anything, but nobody does that. A common beginner mistake is forgetting `self` in the method declaration. The call then fails with `TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given`.

## Class methods and special methods

A method is a function declared inside a class. It sees the object's fields through `self`:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    def with_tax(self, rate=0.2):
        return self.amount * (1 + rate)

    def __repr__(self):
        return f"Expense({self.title!r}, {self.amount})"

coffee = Expense("Coffee", 250)
print(coffee.with_tax())   # 300.0
print(coffee)              # Expense('Coffee', 250)
```

Without `__repr__`, the output would look like `<__main__.Expense object at 0x7f...>` — useless for debugging. Methods with double underscores on both sides are called special (dunder) methods: Python calls them itself. `__str__` is responsible for a "pretty" output for the user, `__eq__` for comparison with `==`, and `__len__` for `len(obj)`.

### @classmethod and @staticmethod

An ordinary method works with an instance. A method decorated with `@classmethod` receives the class itself (`cls`) as its first argument — it is often used as an alternative constructor. A static method marked `@staticmethod` receives neither the object nor the class: it is an ordinary function that logically belongs to the class.

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    @classmethod
    def from_csv(cls, line):
        title, amount = line.split(";")
        return cls(title, float(amount))

    @staticmethod
    def is_valid_amount(value):
        return value >= 0

e = Expense.from_csv("Lunch;520")
print(e.amount)                    # 520.0
print(Expense.is_valid_amount(-1)) # False
```

### A class destructor: __del__

Python has a `__del__` method, which is called before the object is destroyed by the garbage collector. Do not rely on it for releasing resources: the moment of the call is not guaranteed. For files and connections use the `with` context manager or an explicit `close()` method.

## Class attributes and instance attributes

Fields set through `self.name = ...` belong to a specific object. Variables declared directly in the class body are class attributes: they are shared by all instances. This is where class constants are usually stored.

```
class Expense:
    CURRENCY = "USD"          # a class attribute, a constant

    def __init__(self, title, amount):
        self.title = title    # instance attributes
        self.amount = amount

a = Expense("Coffee", 250)
b = Expense("Tea", 150)
print(a.CURRENCY, Expense.CURRENCY)   # USD USD
```

A dangerous trap is a mutable class attribute. If you declare `rows = []` in the class body and add to it through `self.rows.append(...)`, the list becomes shared by all objects. Always create mutable fields in `__init__`.

You can inspect an object's fields with `vars(obj)` or `obj.__dict__`, and read an attribute by its name from a string with `getattr(obj, "amount")`.

## Class properties: @property

A property is a method that is read from outside like an ordinary field, without brackets. This is convenient for computed values: the report's total does not need to be stored separately, and you will not forget to update it.

```
class Report:
    def __init__(self):
        self.rows = []

    def add(self, expense):
        self.rows.append(expense)

    @property
    def total(self):
        return sum(e.amount for e in self.rows)

r = Report()
r.add(Expense("Coffee", 250))
r.add(Expense("Taxi", 300))
print(r.total)   # 550
```

If you need to control writing, add a setter to the property with `@total.setter`. Without a setter, the assignment `r.total = 0` raises `AttributeError`.

A word on encapsulation. Python has no truly private fields. A single underscore (`_cache`) is an agreement "do not touch from outside", while two (`__secret`) trigger name mangling, so that a subclass does not accidentally overwrite the field.

## Class inheritance in Python

Inheritance lets you create a child class based on a parent class: the child gets all the fields and methods, and overrides the ones it needs. The parent class is named in parentheses.

```
class MonthlyReport(Report):
    def __init__(self, month):
        super().__init__()        # call the parent's __init__
        self.month = month

    def add(self, expense):       # method override
        print(f"[{self.month}] + {expense}")
        super().add(expense)      # keep the parent's behaviour

m = MonthlyReport("October")
m.add(Expense("Coffee", 250))   # [October] + Expense('Coffee', 250)
print(m.total)                  # 250 — the property is inherited
```

`super()` returns a proxy object for calling the parent's methods. Forgetting `super().__init__()` in a child's `__init__` is a classic mistake: the `rows` field is never created, and the first `add` fails with `AttributeError`.

You can check the relationship with `issubclass(MonthlyReport, Report)`. All classes in Python 3 implicitly inherit from `object` — this is where the base `__repr__`, `__eq__` and other methods come from. Python also supports multiple inheritance (`class C(A, B)`); the lookup order of methods is shown by `C.__mro__`.

### An abstract class in Python

An abstract class defines an interface: which methods subclasses must implement. You cannot create an instance of it. The `abc` module serves this purpose:

```
from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, report): ...

class CsvExporter(Exporter):
    def export(self, report):
        return "\n".join(f"{e.title};{e.amount}" for e in report.rows)

Exporter()   # TypeError: Can't instantiate abstract class Exporter ...
```

A subclass that does not implement all abstract methods also remains abstract, and will raise the same error when you try to create an object from it.

## Composition instead of deep hierarchies

Once you have met inheritance, you may want to build long ladders: a base expense, a monetary expense, an expense in dollars — five levels deep. Python code rarely does this. Inheritance describes an "is-a" relationship, while composition describes "has-a": a report is not a list of expenses, it contains one in its `rows` field.

A practical rule: if you only need to use another object's ability, store the object in a field rather than inheriting from it. For data, a dataclass is often enough, and the logic can live in ordinary functions.

## Dataclasses in Python: a class without boilerplate

For classes that mainly store data, writing `__init__`, `__repr__` and `__eq__` by hand is tedious. The `@dataclass` decorator from the `dataclasses` module generates them from the field annotations:

```
from dataclasses import dataclass, field

@dataclass
class Expense:
    title: str
    amount: float
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("The amount cannot be negative")

e = Expense("Coffee", 250)
print(e)                           # Expense(title='Coffee', amount=250, tags=[])
print(e == Expense("Coffee", 250))   # True
```

Value checks that used to be written in `__init__` move to `__post_init__` — it is called right after the generated initialiser. Mutable default values are set only through `field(default_factory=list)`: the dataclass forbids the form `tags: list = []` and raises `ValueError`.

### frozen and slots

The `frozen=True` parameter makes an object immutable, and `slots=True` (since Python 3.10) creates `__slots__` and saves memory. An attempt to change a field of a frozen object raises `FrozenInstanceError`. To get an object with a new value, create a copy:

```
from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Expense:
    title: str
    amount: float

e = Expense("Coffee", 250)
# e.amount = 300   -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2)   # Expense(title='Coffee', amount=250) Expense(title='Coffee', amount=300)
```

Since Python 3.13 the general-purpose function `copy.replace()` does the same. Frozen dataclass objects can be put into sets and used as dictionary keys — they have a `__hash__`.

## What is new for classes: Self, @override, __slots__

For typing classes there are two useful tools. `typing.Self` (Python 3.11, PEP 673) means "an object of the same class will be returned" — this works in inherited methods too. The `@override` decorator (Python 3.12, PEP 698) marks a method that must override a parent method: if you misspell the name, mypy or pyright will report that there is no base method with that name.

```
from typing import Self, override

class Report:
    def __init__(self):
        self.rows = []

    def add(self, e) -> Self:
        self.rows.append(e)
        return self              # can be chained

class MonthlyReport(Report):
    @override
    def add(self, e) -> Self:
        return super().add(e)
```

Python 3.15 lifted a niche restriction: subclasses of `tuple` are allowed non-empty `__slots__`, and `__dict__` and `__weakref__` can be listed in the `__slots__` of any class. In 3.14, such a declaration in a tuple subclass failed with `TypeError: nonempty __slots__ not supported for subtype of 'tuple'`.

## Common mistakes when working with classes

| Mistake | Symptom | How to fix |
| --- | --- | --- |

| `self` forgotten in a method | `TypeError: ... takes 0 positional arguments but 1 was given` | The first parameter of a method is always `self` |

| A field without `self.` in `__init__` | The value disappears after `__init__` | Write `self.amount = amount` |

| A list as a class attribute | Data "leaks" between objects | Create lists in `__init__` or through `default_factory` |

| No `super().__init__()` | `AttributeError` on the parent's fields | Call the parent's initialiser |

| Calling a property with brackets | `TypeError: 'int' object is not callable` | Read the property without brackets: `r.total` |

## Summary

Python OOP starts simply: a class describes fields and methods, `__init__` fills in the fields, and `self` refers to the current object. Inheritance and `super()` let you extend behaviour, abstract classes define interfaces, and `@property` creates computed fields. For data classes use `@dataclass`, and prefer composition over deep hierarchies. In practice, classes and dataclasses are covered in a lecture of our Python course using the expense tracker as an example.

## FAQ

**How is a class different from an object in Python?**

A class is a description of a type: which fields and methods it has. An object, or instance, is created by calling the class, for example Expense("Coffee", 250), and holds specific field values.

**Is it mandatory to write __init__ in a class?**

No. If __init__ is not declared, the one inherited from object is used, which accepts no arguments. In a dataclass the decorator generates it.

**How does __str__ differ from __repr__?**

__repr__ is an unambiguous representation for developers and the debugger, while __str__ is a readable output for the user in print and f-strings. If __str__ is missing, Python uses __repr__.

**When should I choose a dataclass and when an ordinary class?**

A dataclass suits a class that mainly stores data and needs ready-made __init__, __repr__ and comparison. An ordinary class is more convenient when complex initialisation logic or behaviour matters more than data.

**Does Python have private fields?**

There is no strict privacy. A single underscore at the start of a name is an agreement "not for external use"; two underscores trigger name mangling, which protects the field from accidental overriding in a subclass.


---
Source: Skillaria.Top (https://skillaria.top)
