Объектно-ориентированное программирование в 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__. - Аннотации типов — как типизировать классы, атрибуты и методы.