Перейти к основному содержимому

ООП в 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 - экземпляробычный метод, работает с данными объекта
@classmethodcls - классальтернативный конструктор, работа с самим классом
@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

Дополнительно​