← назад к разделу

Объектно-ориентированное программирование в Python устроено проще, чем кажется по учебникам с UML-диаграммами. Класс — это всего лишь способ держать вместе данные и функции, которые с этими данными работают. Разберёмся, как объявить класс, когда он действительно нужен, а когда хватит обычной функции.

Зачем вообще класс

Представьте, что вы считаете заказ: цена, количество, скидка. Можно таскать эти три числа по всем функциям отдельными аргументами. А можно завести один объект заказ, у которого данные и поведение лежат рядом. Когда несколько значений всегда ходят вместе и над ними есть осмысленные операции — это сигнал, что пора сделать класс.

Короткая формула: класс — это данные + методы, которые знают, как с этими данными обращаться.

Класс, init и self

Класс объявляют ключевым словом class. Метод __init__ — это конструктор: он вызывается, когда вы создаёте объект, и заполняет его начальными данными. Первый параметр любого метода — self, ссылка на сам объект.

class Order:
    def __init__(self, price: float, quantity: int):
        self.price = price        # атрибут экземпляра
        self.quantity = quantity

    def total(self) -> float:     # метод
        return self.price * self.quantity

order = Order(100.0, 3)   # вызывается __init__, self передаётся автоматически
print(order.total())      # 300.0

Атрибуты — это переменные, привязанные к объекту (self.price). Методы — функции внутри класса, у которых первым идёт self. Сам self вы не передаёте при вызове: order.total() Python превращает в Order.total(order) за вас.

Важно отличать атрибут экземпляра от атрибута класса:

class Order:
    currency = "RUB"          # атрибут класса — общий для всех экземпляров

    def __init__(self, price: float):
        self.price = price    # атрибут экземпляра — свой у каждого объекта

currency один на все заказы, price — свой у каждого.

Наследование и миксины

Наследование позволяет одному классу взять поведение другого и дополнить его. Базовый класс указывают в скобках.

class Product:
    def __init__(self, name: str, price: float):
        self.name = name
        self.price = price

    def describe(self) -> str:
        return f"{self.name}: {self.price}"


class DiscountedProduct(Product):
    def __init__(self, name: str, price: float, discount: float):
        super().__init__(name, price)   # вызываем конструктор базового класса
        self.discount = discount

    def final_price(self) -> float:
        return self.price * (1 - self.discount)

super().__init__(...) обращается к методу родителя — так не нужно переписывать его логику заново. DiscountedProduct получает describe бесплатно и добавляет своё.

Python поддерживает множественное наследование, и на нём строятся миксины — небольшие классы с одной узкой возможностью, которую подмешивают к основному. Миксин обычно не имеет своего __init__ и не используется самостоятельно.

class JsonMixin:
    def to_json(self) -> str:
        import json
        return json.dumps(self.__dict__)


class User(JsonMixin):
    def __init__(self, name: str):
        self.name = name


print(User("Аня").to_json())   # {"name": "Аня"}

User получил умение to_json из миксина, ничего не зная о его устройстве. Миксины удобны, когда одну и ту же возможность нужно добавить разным классам без копирования.

Дандер-методы

Дандер-методы (от double underscore, двойное подчёркивание) — это специальные методы с именами вида __name__, которые Python вызывает в ответ на синтаксис языка. Вы их не вызываете напрямую — их дёргают print(), ==, оператор сравнения и так далее. Три самых полезных:

class Money:
    def __init__(self, amount: int, currency: str = "RUB"):
        self.amount = amount
        self.currency = currency

    def __repr__(self) -> str:        # для разработчика, видно в отладке
        return f"Money(amount={self.amount!r}, currency={self.currency!r})"

    def __str__(self) -> str:         # для человека, видно в print()
        return f"{self.amount} {self.currency}"

    def __eq__(self, other) -> bool:  # сравнение через ==
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency


m = Money(100)
print(m)                  # 100 RUB        — сработал __str__
print(repr(m))            # Money(amount=100, currency='RUB') — __repr__
print(m == Money(100))    # True           — сработал __eq__

__repr__ — однозначное техническое представление (его показывает отладчик и интерпретатор), __str__ — читаемое для пользователя. Если определить только __repr__, он подменит и __str__. __eq__ управляет тем, как ведёт себя ==: без него два разных объекта с одинаковыми полями считаются неравными.

Duck typing

В Python принято смотреть не на тип объекта, а на то, что он умеет. Duck typing — «если что-то крякает как утка, то это утка»: если у объекта есть нужный метод, его можно использовать, неважно, какого он класса.

class PdfReport:
    def render(self) -> str:
        return "PDF"


class HtmlReport:
    def render(self) -> str:
        return "HTML"


def publish(report):          # не проверяем тип, просто зовём render()
    print(report.render())


publish(PdfReport())          # PDF
publish(HtmlReport())         # HTML

Функции publish всё равно, какой класс ей передали — лишь бы у объекта был метод render. Поэтому в Python часто не нужны общие базовые классы или интерфейсы ради совместимости: достаточно одинакового набора методов.

@property

Иногда хочется, чтобы значение вычислялось при обращении, но обращались к нему как к обычному атрибуту, без скобок. Для этого есть декоратор @property.

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    @property
    def area(self) -> float:
        return 3.14159 * self.radius ** 2


c = Circle(10)
print(c.area)       # 314.159 — без скобок, хотя это метод

area выглядит как атрибут, но считается на лету. @property также позволяет добавить проверку при записи через парный сеттер — это способ контролировать значение, не ломая привычный синтаксис obj.attr.

dataclass как краткий класс данных

Когда класс нужен в основном для хранения данных, писать __init__, __repr__ и __eq__ вручную утомительно. Декоратор @dataclass из модуля dataclasses генерирует их за вас по аннотациям полей.

from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


p = Point(1, 2)
print(p)              # Point(x=1, y=2)   — __repr__ уже есть
print(p == Point(1, 2))   # True          — __eq__ уже есть

Один декоратор заменил весь шаблонный код. Можно задать значения по умолчанию, сделать объект неизменяемым через @dataclass(frozen=True). Для простых структур данных dataclass — первый выбор.

Когда ООП, когда функции

Класс — не цель сам по себе. Он оправдан, когда:

  • несколько данных всегда ходят вместе и имеют общее поведение;
  • нужно хранить состояние между вызовами;
  • есть семейство похожих сущностей, где уместно наследование или общий набор методов.

Обычной функции достаточно, когда:

  • это одна операция «вход → выход» без состояния;
  • данные удобно представить встроенными типами (dict, список, кортеж);
  • класс получился бы с единственным методом и __init__ — тогда это просто функция в маскировке.

Короткая формула: если у класса один метод и больше ничего — скорее всего, нужна функция.

Коротко

  • Класс держит данные (self.attr) и методы рядом; __init__ — конструктор, self — ссылка на объект, передаётся автоматически.
  • Атрибут класса общий для всех экземпляров, атрибут экземпляра — свой у каждого.
  • Наследование с super().__init__(...) переиспользует поведение родителя; миксины добавляют узкую возможность через множественное наследование.
  • Дандер-методы (__str__, __repr__, __eq__) подключают объект к синтаксису языка — печати и сравнению.
  • Duck typing: важно, что объект умеет, а не какого он класса.
  • @property даёт вычисляемый атрибут без скобок; @dataclass генерирует шаблонный код для классов-данных.
  • Если у класса один метод — вероятно, хватит функции.

Что почитать дальше

  • Функции и модули — где функции уместнее классов и как раскладывать код по модулям.
  • Исключения и контекстные менеджеры — обработка ошибок и протокол with через дандер-методы __enter__/__exit__.
  • Аннотации типов — как типизировать классы, атрибуты и методы.