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

PYTHON · OBJECT MODEL Фаза 2. Основы языка: Python

Обязательно

ООП в Python: классы, наследование и дандер-методы

Python · Dataclasses · MRO

self, наследование, mixins, property, dataclass и duck typing.

1. Класс хранит данные и поведение

1 / 5

__init__ инициализирует объект, self ссылается на конкретный экземпляр. Поля и операции живут рядом.

Orderself.price · self.quantitytotal() computes valuedata + methods together

2. Наследование переиспользует контракт

2 / 5

PaidOrder extends Order и добавляет paymentId. super вызывает родительскую инициализацию вместо копирования полей.

OrderPaidOrdersuper().__init__()+ paymentId

3. MRO определяет поиск метода

3 / 5

При ромбовидном наследовании C3 linearization задаёт порядок классов. Миксины добавляют поведение, но порядок важен.

BaseMixin AMixin BChild → A → B → Base

4. Дандер-методы задают протокол

4 / 5

__str__ отвечает print, __eq__ сравнение, __hash__ использование в set/dict. @property вызывает метод как атрибут.

__str__readable print__eq__ + __hash__value semantics@propertycomputed attribute

5. Dataclass убирает шаблон

5 / 5

Автоматически создаёт init, repr и eq. Duck typing принимает объект по методам, а не по конкретному классу.

@dataclassinit + repr + eqDuck typingmethods, not class names

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

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

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

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

Класс, init и self

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

live example

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
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

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

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

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

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

Инкапсуляция: соглашения вместо модификаторов

В Python нет private и protected. Вместо них два соглашения, и интерпретатор поддерживает только второе:

class Account:
    def __init__(self, owner: str, balance: int):
        self.owner = owner
        self._balance = balance        # одно подчёркивание: «внутреннее, снаружи не трогать»
        self.__audit = []              # два подчёркивания: имя искажается, снаружи так не достать

    def deposit(self, amount: int) -> None:
        if amount <= 0:
            raise ValueError("сумма должна быть положительной")
        self._balance += amount
        self.__audit.append(("deposit", amount))

    @property
    def balance(self) -> int:          # чтение через свойство, записи снаружи нет
        return self._balance


acc = Account("Анна", 100)
acc.deposit(50)
print(acc.balance)                     # 150
print(acc._balance)                    # 150: доступно, но линтер и ревью скажут «так не надо»
print(acc._Account__audit)             # [('deposit', 50)]: вот куда Python спрятал __audit
acc.balance = 0                        # AttributeError: property без setter

Одно подчёркивание это договор между людьми: атрибут не входит в публичный интерфейс и может измениться без предупреждения. Два подчёркивания включают искажение имени (_Account__audit), и это защита не от злоумышленника, а от случайного совпадения имён в наследниках. Настоящую границу задаёт @property без setter и методы, которые проверяют инварианты: снаружи баланс меняется только через deposit, и правило «сумма положительная» живёт в одном месте.

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

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

User JsonMixin Base object

class User(JsonMixin, Base): порядок разрешения методов идёт слева направо, метод берут у первого, у кого он есть; поменяли местами родителей — победит другой to_json.

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__ и не используется самостоятельно.

live example

import json

class JsonMixin:
    def to_json(self) -> str:
        return json.dumps(self.__dict__, ensure_ascii=False)

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

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

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

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

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

live example

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

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

Здесь есть подводный камень: как только класс определил __eq__, Python убирает у него __hash__, и объекты перестают годиться в ключи словаря и элементы множества. Если равенство нужно вместе с хешированием, определяют и __hash__ по тем же полям, а проще взять @dataclass(frozen=True), который сделает это сам.

Duck typing

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

live example

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
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

Абстрактные классы и Protocol

Duck typing работает, пока ошибка «нет метода render» всплывает в тесте, а не в проде. Когда набор методов нужно зафиксировать, есть два способа. Абстрактный базовый класс из abc требует реализовать методы при наследовании и не даёт создать экземпляр с дырой:

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    def describe(self) -> str:                      # общий код живёт в базовом классе
        return f"{type(self).__name__}: {self.area():.1f}"


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

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


class Square(Shape):
    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side ** 2


for shape in (Circle(1), Square(2)):
    print(shape.describe())                         # Circle: 3.1 / Square: 4.0

Shape()                                             # TypeError: нельзя создать абстрактный класс

Protocol фиксирует тот же набор методов без наследования: класс подходит, если у него есть нужные методы, а проверяет это mypy или pyright до запуска. Это duck typing с проверкой типов, и для портов в гексагональной архитектуре берут именно его:

live example

from typing import Protocol


class Notifier(Protocol):
    def send(self, message: str) -> None: ...


class EmailNotifier:                                # не наследует Notifier, но подходит по форме
    def send(self, message: str) -> None:
        print("email:", message)


class SmsNotifier:
    def send(self, message: str) -> None:
        print("sms:", message)


def notify_all(notifiers: list[Notifier], message: str) -> None:
    for notifier in notifiers:
        notifier.send(message)


notify_all([EmailNotifier(), SmsNotifier()], "заказ отправлен")
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Правило выбора: ABC, когда у наследников есть общий код и общая цепочка создания; Protocol, когда нужна только форма, особенно для чужих классов, которые вы не можете заставить наследовать ваш базовый.

@property

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

live example

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 — без скобок, хотя это метод
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

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

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

live example

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__ уже есть
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

enum — перечисление

Статус заказа как строка "paid" ломается от опечатки, и проверить, какие значения вообще бывают, негде. Перечисление даёт закрытый набор именованных значений:

from enum import Enum, StrEnum


class Status(StrEnum):                              # StrEnum: члены равны своим строкам
    NEW = "new"
    PAID = "paid"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"


status = Status("paid")                             # из строки, пришедшей из базы или запроса
print(status, status.name, status == "paid")        # paid PAID True
print(list(Status))                                 # все значения, в порядке объявления
print(Status.NEW in {Status.NEW, Status.PAID})      # True
Status("refunded")                                  # ValueError: не член перечисления

У члена перечисления могут быть свои данные и методы, и это заменяет таблицу констант с параллельными словарями:

live example

from enum import Enum


class Planet(Enum):
    EARTH = 9.8
    MARS = 3.7

    def weight(self, mass_kg: float) -> float:
        return mass_kg * self.value


class Status(Enum):
    NEW = "new"
    PAID = "paid"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"

    def can_cancel(self) -> bool:                   # правило переходов живёт рядом с данными
        return self in {Status.NEW, Status.PAID}


print(f"{Planet.MARS.weight(70):.1f}")              # 259.0
print(Status.PAID.can_cancel(), Status.SHIPPED.can_cancel())   # True False
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

StrEnum удобен на границах: сериализуется в строку как есть и сравнивается со строкой. Обычный Enum строже, он не равен своему значению, и это защищает от сравнения яблок с апельсинами внутри домена.

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

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

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

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

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

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

Дополнительно: при первом чтении можно пропустить

Глубже: дескрипторы: как устроены property и методырасширенное

@property не магия интерпретатора, а частный случай общего механизма. Дескриптор это объект с методом __get__, а если он умеет ещё и __set__, то дескриптор данных. Когда Python встречает obj.attr, он сначала ищет attr в классе, и если нашёл дескриптор данных, зовёт его __get__(obj, type(obj)) вместо того, чтобы читать obj.__dict__. Так property перехватывает чтение и запись, а обычная функция в классе, у которой есть только __get__, при обращении через объект привязывает self и становится методом. Из того же механизма растут classmethod, staticmethod и поля ORM.

Свой дескриптор пишут, когда одну и ту же проверку надо повесить на десяток атрибутов, и копировать property в каждый класс не хочется:

live example

class Positive:
    def __set_name__(self, owner, name):
        self.name = "_" + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.name[1:]} должен быть больше нуля")
        setattr(obj, self.name, value)


class Order:
    price = Positive()
    quantity = Positive()

    def __init__(self, price, quantity):
        self.price = price
        self.quantity = quantity


order = Order(100, 2)
print(order.price * order.quantity)  # 200
try:
    order.quantity = 0
except ValueError as e:
    print(e)  # quantity должен быть больше нуля
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

__set_name__ сообщает дескриптору, под каким именем его положили в класс, а значение он хранит в экземпляре под именем с подчёркиванием. Порядок поиска атрибута стоит запомнить целиком: дескриптор данных из класса, потом __dict__ экземпляра, потом дескриптор без __set__ и обычные атрибуты класса, и только потом __getattr__.

Глубже: new, метаклассы и интроспекциярасширенное

Создание объекта идёт в два шага. __new__ это статический метод, который выделяет и возвращает новый экземпляр, а __init__ получает уже созданный объект и наполняет его. Переопределяют __new__ редко: когда нужно вернуть не новый объект, а существующий, например единственный экземпляр, или когда наследуются от неизменяемого типа вроде tuple и значение надо задать до инициализации. Если __new__ вернул объект другого класса, __init__ не вызывается вовсе.

Класс в Python тоже объект, и его создаёт метакласс, по умолчанию type: запись class Order: ... это вызов type("Order", (), {...}). Свой метакласс наследуют от type и переопределяют __new__ или __init__, чтобы вмешаться в момент создания класса: проверить, что у наследника есть нужные методы, зарегистрировать его в реестре, добавить атрибуты. Так работают abc.ABCMeta, Enum и модели ORM. Честный ответ на вопрос «зачем они нужны» звучит так: почти никогда, потому что __init_subclass__ и декораторы класса решают те же задачи проще и читаются без подготовки.

live example

class Registry(type):
    handlers = {}

    def __new__(mcls, name, bases, namespace):
        cls = super().__new__(mcls, name, bases, namespace)
        if "event" in namespace:
            mcls.handlers[namespace["event"]] = cls
        return cls


class Handler(metaclass=Registry):
    pass


class OrderPaid(Handler):
    event = "order.paid"


class OrderShipped(Handler):
    event = "order.shipped"


print(sorted(Registry.handlers))  # ['order.paid', 'order.shipped']
print(type(OrderPaid) is Registry, isinstance(OrderPaid, type))  # True True
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Раз всё объект, в него можно заглянуть на лету: type(x) и isinstance, dir(x) перечисляет атрибуты, getattr, setattr и hasattr работают с атрибутом по имени-строке, vars(x) показывает __dict__, а модуль inspect разбирает сигнатуры функций и исходный код. Это и есть интроспекция; на ней держатся dataclass, FastAPI, который читает аннотации обработчика, и Pydantic. Обратная сторона: код, который строит поведение из строк через getattr, не проверяется ни mypy, ни глазами, поэтому в прикладной логике его держат на границе, а не в центре.

Глубже: classmethod, staticmethod, порядок миксинов и slotsрасширенное

Кроме обычных методов у класса бывают два особых вида. @classmethod получает первым аргументом сам класс, а не экземпляр, и чаще всего служит альтернативным конструктором; @staticmethod не получает ничего и просто живёт рядом с классом, потому что по смыслу относится к нему:

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

    @classmethod
    def parse(cls, text: str) -> "Money":        # Money.parse("100 RUB")
        amount, currency = text.split()
        return cls(int(amount), currency)

    @staticmethod
    def is_supported(currency: str) -> bool:     # Money.is_supported("EUR")
        return currency in {"RUB", "EUR", "USD"}

Порядок баз при множественном наследовании важен: Python ищет метод слева направо по линии User.__mro__, поэтому миксины ставят левее основного класса - class User(JsonMixin, Base), а не наоборот.

Атрибут класса, который является изменяемым объектом, общий для всех экземпляров: если написать tags = [] на уровне класса и делать self.tags.append(...), теги начнут появляться у всех объектов сразу. Изменяемые поля создают в __init__.

Когда объектов миллионы, __slots__ = ("amount", "currency") или @dataclass(slots=True) убирает у экземпляра словарь атрибутов: памяти уходит заметно меньше, доступ к полям быстрее, зато новый атрибут на лету добавить нельзя.

Коротко

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

Что пощупать

Класс Product в практикуме remodov/marketplace-system-python создаётся через @classmethod create, хранит состояние в атрибутах с подчёркиванием и отдаёт его свойствами без сеттеров: p.price = x поднимает AttributeError. Правило скидки живёт в методе, деньги считаются в Decimal с явным quantize(..., ROUND_HALF_UP), потому что умолчание ROUND_HALF_EVEN даст 0.12 из 0.125.

Код: model.py, test_rules.py.

Сделаем сами

Ветка step-04-domain-rules.

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