ООП в Python
Классы хранят данные вместе с поведением, которое с ними работает - вместо кучи отдельных
функций и словарей получается один связный объект
Тема большая: от self и обычных классов до наследования, абстрактных классов и метаклассов -
и весь этот аппарат напрямую пригодится в Django: модели, формы и Class-Based Views - это классы,
которые наследуются и компонуются через миксины
Теория
Классы и объекты
Класс - шаблон, объект (экземпляр) - конкретный «предмет», созданный по этому шаблону:
class User:
def __init__(self, username, email):
self.username = username # атрибут экземпляра - свой у каждого объекта
self.email = email
def greet(self):
return f"Привет, {self.username}"
user = User("alice", "alice@example.com")
user.greet() # 'Привет, alice'
self - первый параметр любого метода, ссылка на конкретный экземпляр, через который метод и
достаёт свои атрибуты
Питон подставляет его автоматически: user.greet() под капотом - это User.greet(user)
Атрибуты можно объявлять и на уровне класса - тогда они общие для всех экземпляров, пока кто-то явно не создаст одноимённый атрибут экземпляра:
class User:
role = "student" # атрибут класса - один на всех
def __init__(self, username):
self.username = username
User.role # 'student'
User("bob").role # 'student' - берётся из класса
u = User("bob")
u.role = "admin" # создаёт АТРИБУТ ЭКЗЕМПЛЯРА - класс не трогает
User("carl").role # всё ещё 'student'
Модификаторы доступа
В Python нет настоящих private/protected как в Java или C++ - только соглашение об
именовании, которое НЕ мешает доступу технически:
username- публичный, без ограничений_username- «не лезь снаружи», но физически доступен (protected по соглашению)__username- Python переименовывает атрибут (name mangling) в_User__username, что усложняет случайный доступ, но не запрещает его
class User:
def __init__(self, username):
self.__username = username
def get_username(self):
return self.__username
user = User("alice")
user.__username # AttributeError - имени `__username` нет
user._User__username # 'alice' - но так технически можно
Методы класса и статические методы
Три вида методов различаются тем, что они получают первым а ргументом:
| Декоратор | Первый аргумент | Когда |
|---|---|---|
| (нет) | self - экземпляр | обычный метод, работает с данными объекта |
@classmethod | cls - класс | альтернативный конструктор, работа с самим классом |
@staticmethod | ничего | вспомогательная функция, логически живущая в классе |
class User:
def __init__(self, username, email):
self.username = username
self.email = email
@classmethod
def from_dict(cls, data):
return cls(data["username"], data["email"]) # cls - это User, можно и унаследовать
@staticmethod
def is_valid_email(email):
return "@" in email
user = User.from_dict({"username": "alice", "email": "alice@example.com"})
User.is_valid_email("bad-email") # False
@classmethod часто используют именно для альтернативных конструкторов (from_dict,
from_json) - обычный __init__ не может как-то иначе называться, а classmethod может
Магические методы
Методы вида __что-то__ - протоколы, через которые объект встраивается в язык (print,
len(), obj[key], сравнение и т.д.):
| Метод | Вызывается при |
|---|---|
__init__ | создании объекта - User(...) |
__repr__ | отладочном представлении - консоль, repr(obj) |
__str__ | print(obj), str(obj) - для человека |
__eq__ | obj1 == obj2 |
__len__ | len(obj) |
__getitem__ | obj[key] |
class User:
def __init__(self, username):
self.username = username
def __repr__(self):
return f"User(username={self.username!r})"
def __eq__(self, other):
return isinstance(other, User) and self.username == other.username
alice = User("alice")
alice # User(username='alice') - вызвал __repr__
alice == User("alice") # True
Если не определить __repr__, консоль покажет что-то малополезное вроде адреса объекта в
памяти - поэтому __repr__ стоит писать почти всегда
Dataclass
Когда класс - в основном контейнер данных, @dataclass сам генерирует __init__, __repr__
и __eq__ по объявленным полям:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # значение по умолчанию
user = User("alice", "alice@example.com")
user # User(username='alice', email='alice@example.com', is_active=True)
user == User("alice", "alice@example.com") # True - __eq__ уже есть
Ловушка с мутабельным значением по умолчанию - если по умолчанию: список, словарь или
другой изменяемый объект, @dataclass создаст его ОДИН РАЗ и разделит между всеми
экземплярами класса:
from dataclasses import dataclass, field
@dataclass
class Broken:
tags: list[str] = [] # ValueError - dataclass сам это запрещает при объявлении класса
@dataclass
class Fixed:
tags: list[str] = field(default_factory=list) # правильно - своя копия на каждый экземпляр
a = Fixed()
b = Fixed()
a.tags.append("x")
b.tags # [] - не расшарен, у каждого своя копия
Та же логика для dict, set и других изменяемых значений по умолчанию - напрямую в
аннотации @dataclass их объявить не даст, только через field(default_factory=...)
Ещё два полезных параметра:
frozen=True- делает экземпляры неизменяемыми, попытка присвоить атрибут после создания броситFrozenInstanceError__post_init__- метод, который@dataclassвызовет сразу после автосгенерированного__init__, удобно для валидации полей
Enum
Когда у поля - фиксированный набор значений (статус, роль), Enum явно это выражает - вместо
случайных строк/чисел, которые нигде не описаны:
from enum import Enum, auto
class UserStatus(Enum):
ACTIVE = auto()
BANNED = auto()
DELETED = auto()
status = UserStatus.ACTIVE
status == UserStatus.ACTIVE # True
status.name # 'ACTIVE'
status.value # 1 - то, что реально лежит под ACTIVE (auto() пронумеровал с 1)
auto() просто нумерует значения по порядку - для API это неудобно (число само по себе ничего
не говорит о смысле), поэтому в коде, который отдаёт/принимает JSON, чаще используют StrEnum
с явными строковыми значениями:
from enum import StrEnum
class UserStatus(StrEnum):
ACTIVE = "active"
BANNED = "banned"
DELETED = "deleted"
status = UserStatus.ACTIVE
status == "active" # True - StrEnum сравнивается со строкой напрямую
str(status) # 'active' - не 'UserStatus.ACTIVE', как было бы у обычного Enum
StrEnum появился в Python 3.11 - до него для той же цели использовали class UserStatus(str, Enum)
Работа с файлами
Пригодится ниже, когда классы будут сохранять себя на диск:
with open("data.txt", "w") as f:
f.write("alice\n")
f.write("bob\n")
with open("data.txt", "r") as f:
lines = f.readlines() # ['alice\n', 'bob\n']
import os
os.path.exists("data.txt") # True
with закрывает файл автоматически, даже если внутри блока случится исключение - открывать
файл без with и вручную звать f.close() смысла нет
Современная альтернатива os.path - модуль pathlib, он же используется дальше в разделе
«Примеры»: путь - это объект Path, а не строка, и у него сразу есть нужные методы:
from pathlib import Path
path = Path("data.txt")
path.exists() # True - вместо os.path.exists(path)
path.write_text("alice\nbob\n") # открыть, записать, закрыть - без ручного with
path.read_text() # 'alice\nbob\n' - строка целиком, без readlines()
write_text/read_text сами открывают файл, пишут/читают и закрывают - with тут не нужен,
он спрятан внутри метода Path
Наследование, super() и @property
Наследование - новый класс берёт поведение родителя и может его дополнять/переопределять:
from typing import Any
class BaseModel:
def __init__(self, id_: int) -> None:
self._id = id_
@property
def id(self) -> int: # доступ как к атрибуту: user.id, а не user.id()
return self._id
def __repr__(self) -> str:
return f"{self.__class__.__name__}(id={self.id!r})"
def to_dict(self) -> dict[str, Any]:
return {"id": self.id}
class User(BaseModel):
def __init__(self, id_: int, username: str) -> None:
super().__init__(id_) # вызвать __init__ родителя - не дублировать логику
self.username = username
def to_dict(self) -> dict[str, Any]:
return {**super().to_dict(), "username": self.username} # переиспользовать родителя
user = User(id_=1, username="alice")
user.id # 1 - через property
user.to_dict() # {'id': 1, 'username': 'alice'}
@property превращает метод в атрибут «только для чтения», а @x.setter добавляет к нему
запись - и туда можно вставить проверку, а не просто присвоить значение как есть:
class User(BaseModel):
def __init__(self, id_: int, email: str) -> None:
super().__init__(id_)
self.email = email # уже проходит через сеттер ниже
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
if "@" not in value:
raise ValueError(f"некорректный email: {value!r}")
self._email = value
user = User(id_=1, email="alice@example.com")
user.email = "bad-email" # ValueError - сеттер не пропустит
Без @property пришлось бы либо звать отдельный метод (user.set_email(...)), либо вообще не
проверять значение при обычном присваивании user.email = ...
Миксины
Миксин - класс, который не предназначен для самостоятельного использования, а добавляет ОДНУ конкретную способность через множественное наследование:
import json
class JsonSerializableMixin:
def to_json(self):
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, raw):
return cls.from_dict(json.loads(raw))
class User(JsonSerializableMixin, BaseModel):
def __init__(self, id, username):
super().__init__(id)
self.username = username
def to_dict(self):
return {**super().to_dict(), "username": self.username}
@classmethod
def from_dict(cls, data):
return cls(data["id"], data["username"])
user = User(1, "alice")
user.to_json() # '{"id": 1, "username": "alice"}'
User.from_json(user.to_json()) # User(id=1)
Порядок родителей важен - Python ищет метод слева направо (MRO), поэтому миксины обычно
пишут ПЕРЕД основным классом
Это ровно тот же механизм, на котором в Django построены Class-Based Views: LoginRequiredMixin,
CreateView и другие миксины комбинируются множественным наследованием в один класс-обработчик
Duck typing
В Java/C++/C# явно объявленный интерфейс - обычно необходимое условие для полиморфизма: класс
должен явно реализовать interface/abstract class, чтобы его можно было передать туда, где
ожидается этот тип
В Python это не так - Python не проверяет класс объекта перед вызовом метода, только сам факт,
что нужный метод есть
Если объект «крякает как утка» - у него есть нужный метод - он подходит, независимо от того,
от чего он унаследован:
class Duck:
def make_sound(self):
return "Кря!"
class Dog:
def make_sound(self):
return "Гав!"
def print_sound(animal):
print(animal.make_sound()) # неважно, что за класс - лишь бы был make_sound()
print_sound(Duck()) # Кря!
print_sound(Dog()) # Гав!
JsonEntityManager из примеров ниже работает с любой моделью, у которой есть
to_dict()/from_dict() - ей необязательно наследоваться от общего Serializable, достаточно,
что нужные методы просто есть
ABC из следующего раздела - способ ЗАФИКСИРОВАТЬ такой контракт явно и получить ошибку сразу
при создании объекта
Duck typing - более распространённый в Python подход, когда явная фиксация контракта не нужна:
код просто вызывает метод и полагается на то, что он есть (EAFP - "легче попросить прощения,
чем разрешения", вместо предварительной проверки isinstance)
Protocol - типизация duck typing
Duck typing выше нигде не фиксирует контракт для type checker'а - mypy видит
def print_sound(animal) и не может подсказать, какие методы у animal должны быть
Protocol из typing описывает нужные методы/атрибуты, но, в отличие от ABC, не требует
явного наследования - подходит любой класс, у которого нужные методы просто есть (структурная
типизация)
from typing import Protocol
class SoundMaker(Protocol):
def make_sound(self) -> str: ...
def print_sound(animal: SoundMaker) -> None:
print(animal.make_sound())
class Duck:
def make_sound(self) -> str:
return "Кря!"
print_sound(Duck()) # mypy доволен - Duck нигде не наследуется от SoundMaker,
# но нужный метод у него есть
Так же можно было бы явно описать контракт JsonEntityManager из примеров ниже - модель обязана
уметь to_dict/from_dict, но необязательно ни от чего наследоваться:
from typing import Protocol
class Serializable(Protocol):
def to_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict) -> "Serializable": ...
Если вдобавок нужен isinstance(obj, SoundMaker) в рантайме, класс Protocol можно пометить
@runtime_checkable - но такая проверка смотрит только на наличие метода, не на его сигнатуру
Protocol - промежуточный вариант между ABC (контракт фиксирован и проверяется в рантайме при
создании объекта, но нужно явно наследоваться) и обычным duck typing (наследоваться не нужно, но
и mypy ничего не проверит): контракт виден type checker'у, а наследоваться не нужно
Отсутствие перегрузки методов
В Java/C++/C# можно объявить несколько методов с одним именем, но разными параметрами
(перегрузка/overloading) - компилятор сам выбирает нужную версию по типам и количеству
аргументов
В Python такой перегрузки нет - метод с уже занятым именем просто переопределяет предыдущий:
class Greeter:
def greet(self):
return "Привет!"
def greet(self, name): # это НЕ перегрузка - просто затирает предыдущий greet
return f"Привет, {name}!"
Greeter().greet() # TypeError: greet() missing 1 required positional argument: 'name'
Вместо перегрузки в Python принято:
- значения по умолчанию -
def greet(self, name=None) *args/**kwargs- когда набор аргументов заранее не фиксированfunctools.singledispatchmethod- если нужно разное поведение в зависимости от типа аргумента
Абстрактные классы
ABC описывает интерфейс - какие методы ОБЯЗАН реализовать любой наследник - но сам не
создаёт объектов:
from abc import ABC, abstractmethod
class AbstractEntityManager(ABC):
@abstractmethod
def add(self, entity): ...
@abstractmethod
def get_all(self): ...
@abstractmethod
def save(self): ...
AbstractEntityManager() # TypeError - нельзя создать объект абстрактного класса
class BrokenManager(AbstractEntityManager):
def add(self, entity): ...
def get_all(self): ...
# save не реализован
BrokenManager() # TypeError - не реализован abstractmethod save
Смысл - зафиксировать контракт на уровне языка: если забыл реализовать метод, упадёт сразу при создании объекта, а не где-то посреди работы программы
Метаклассы
Метакласс - «класс для классов»: обычный к ласс создаётся своим метаклассом (по умолчанию -
type) точно так же, как обычный объект - своим классом
Свой метакласс нужен редко, но стоит уметь узнавать паттерн - Django ORM (models.Model)
устроен именно так, метакласс автоматически прописывает поля модели при объявлении класса:
class SimpleManager:
def __init__(self):
self.items = []
class AttachManagerMeta(type):
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
cls.objects = SimpleManager() # добавили атрибут ВСЕМ классам с этим метаклассом
class User(metaclass=AttachManagerMeta):
pass
User.objects # <SimpleManager ...> - появился сам, без ручного кода в User
Обобщённые классы (Generics)
Когда логика класса одинакова для разных типов данных, но хочется, чтобы аннотации типов это отражали (нужен Python 3.12+):
class EntityManager[T]:
def __init__(self, model: type[T]):
self.model = model
def get(self, entity_id) -> T:
...
users = EntityManager[User](User) # mypy знает, что get() вернёт User
Примеры
Собрать всё вместе - маленькое файловое хранилище моделей, растущее ровно теми кусками, что были в теории выше:
import json
from abc import ABC, abstractmethod
from pathlib import Path
class BaseModel:
def __init__(self, id):
self._id = id
@property
def id(self):
return self._id # доступен как атрибут, но не как поле для прямой записи
def to_dict(self):
return {"id": self.id}
def __repr__(self):
return f"{self.__class__.__name__}({self.to_dict()!r})"
class User(BaseModel):
def __init__(self, id, username):
super().__init__(id) # id обрабатывает родитель - не дублируем его логику
self.username = username
def to_dict(self):
return {**super().to_dict(), "username": self.username} # достраиваем словарь родителя
@classmethod
def from_dict(cls, data):
return cls(data["id"], data["username"]) # альтернативный конструктор - из словаря
class AbstractEntityManager(ABC):
@abstractmethod
def add(self, entity): ...
@abstractmethod
def get_all(self): ...
@abstractmethod
def save(self): ... # контракт: любой менеджер обязан уметь add/get_all/save
class JsonEntityManager(AbstractEntityManager):
def __init__(self, model, path):
self.model = model # класс модели - нужен для from_dict при загрузке
self.path = Path(path)
self._items = []
if self.path.exists():
raw = json.loads(self.path.read_text()) # прочитать файл и распарсить JSON
self._items = [self.model.from_dict(item) for item in raw] # запись → объект
def add(self, entity):
self._items.append(entity)
def get_all(self):
return list(self._items) # копия списка - наружу не отдаём внутренний _items напрямую
def save(self):
raw = [item.to_dict() for item in self._items] # каждый объект - обратно в словарь
self.path.write_text(json.dumps(raw)) # сериализуем в JSON и пишем на диск
manager = JsonEntityManager(User, "users.json")
manager.add(User(id=1, username="alice"))
manager.save() # записал users.json
manager.get_all() # [User({'id': 1, 'username': 'alice'})]
AbstractEntityManager тут - для того, чтобы завтра рядом появился, например,
SqliteEntityManager с тем же интерфейсом (add/get_all/save), и остальной код мог
работать с любым из них, не зная, что именно хранит данные
Проверь себя
1. Что такое self и откуда он берётся?
Первый параметр метода - ссылка на конкретный экземпляр
Python подставляет его автоматически: obj.method(x) - это Class.method(obj, x)
2. Чем _username отличается от __username?
Оба - соглашение, не запрет
_username - просьба не трогать снаружи, доступ не меняется
__username - Python переименовывает атрибут (name mangling) в _Class__username, что усложняет
случайный доступ, но не делает его невозможным
3. Когда использовать @classmethod, а когда @staticmethod?
@classmethod - когда методу нужен сам класс (cls), чаще всего для альтернативного
конструктора (from_dict, from_json)
@staticmethod - когда метод логически относится к классу, но не использует ни self, ни cls
4. Зачем нужен __repr__, если уже есть __str__?
__str__ - представление для человека (print(obj))
__repr__ - для отладки/консоли, и это фолбэк: если __str__ не определён, Python использует
__repr__ вместо него
5. Чем @dataclass лучше обычного класса для хранения данных?
Сам генерирует __init__, __repr__ и __eq__ по объявленным полям - не нужно писать это
руками и держать в синхроне при добавлении нового поля
6. Зачем миксину множественное наследование, а не обычное?
Миксин добавляет одну способность (например, сериализацию), которую нужно подмешать в разные, не связанные между собой классы - обычное наследование подошло бы только для одной иерархии
7. Что произойдёт, если не реализовать все @abstractmethod наследника ABC?
TypeError прямо при попытке создать объект - Python не даст инстанцировать класс с
нереализованными абст рактными методами
8. Чем метакласс отличается от обычного наследования?
Наследование меняет, что умеет ЭКЗЕМПЛЯР класса
Метакласс меняет, что умеет сам КЛАСС (и делает это одинаково для всех классов, которые его
используют) - именно так Django ORM собирает поля модели при объявлении class MyModel(models.Model)
9. Что такое duck typing и чем он отличается от ABC?
Duck typing - Python вызывает метод, если он есть у объекта, не проверяя класс или наследование
ABC явно фиксирует контракт и падает при создании объекта, если контракт не выполнен - duck
typing просто попробует вызвать метод и упадёт (или не упадёт) только в момент вызова
10. Что будет, если объявить в классе два метода с одинаковым именем, но разным числом аргументов?
Это не перегрузка - второе определение просто заменит первое
Компилятора, который выбирал бы нужную версию по аргументам, в Python нет - работать будет
только последний объявленный метод
Итог / чек-лист
self- экземпляр,cls- класс,@staticmethod- ни то, ни другое- Модификаторы доступа в Python - только соглашение (
_,__), не технический запрет - Магические методы (
__repr__,__eq__,__getitem__и другие) встраивают класс в язык -print,==,obj[key]работают через них @dataclass- для классов-контейнеров данных, сам пишет__init__/__repr__/__eq__Enum- для фиксированного набора значений вместо произвольных строк/чиселsuper()вызывает метод родителя - не дублирует его логику при переопределении@property- метод, который выглядит как атрибут; можно защитить от прямой записи- Миксин - однонаправленная добавка поведения через множественное наследование (порядок родителей важен - MRO слева направо)
ABC+@abstractmethod- контракт, который проверяется при создании объекта, а не когда-то потом в рантайме- Метакласс - редкий инструмент, но на нём построены Django-модели - полезно узнавать паттерн, а не обязательно писать самому
- Duck typing - Python вызывает метод, если он есть у объекта, не проверяя класс - в отличие от языков с обязательными явными интерфейсами
- В Python нет перегрузки методов - одинаковое имя просто переопределяет предыдущий метод,
вместо неё: значения по умолчанию,
*args/**kwargs,singledispatchmethod