Объектно-ориентированное программирование в 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, и правило «сумма положительная» живёт в одном месте.
Наследование и миксины
Наследование позволяет одному классу взять поведение другого и дополнить его. Базовый класс указывают в скобках.
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.
Что почитать дальше
- Функции и модули — где функции уместнее классов и как раскладывать код по модулям.
- Исключения и контекстные менеджеры — обработка ошибок и протокол
withчерез дандер-методы__enter__/__exit__. - Аннотации типов — как типизировать классы, атрибуты и методы.