1. Абстрактные классы
Абстрактный класс — это класс, который не предназначен для создания объектов напрямую. Он задаёт структуру и обязательные методы, которые должны быть реализованы в дочерних классах.
Зачем нужны
- Определить единый интерфейс для группы классов.
- Заставить наследников реализовать нужные методы.
- Описать общую логику, но запретить создание «незаконченных» объектов.
Отличие от обычного класса
| Аспект | Обычный класс | Абстрактный класс |
|---|---|---|
| Создание объектов | Можно | Нельзя |
| Переопределение методов | Необязательно | Абстрактные методы — обязательны |
Создание абстрактного класса
from abc import ABC, abstractmethod
class Employee(ABC):
"""Абстрактный базовый класс для сотрудников."""
@abstractmethod
def work(self) -> None:
"""Описать рабочий процесс конкретного сотрудника."""
pass
# e = Employee() # TypeError: Cannot instantiate abstract class Employee
class Programmer(Employee):
def work(self) -> None:
print("Write code")
p = Programmer()
p.work() # Write code
Ключевые моменты:
- Класс должен наследоваться от
ABC(модульabc). - Абстрактные методы помечаются декоратором
@abstractmethod. - Абстрактный класс может содержать как абстрактные, так и обычные методы.
Абстрактный метод может содержать код
@abstractmethod не обязывает писать внутри только pass. Метод может содержать общую часть работы, а наследник вызовет её через super() и дополнит своей логикой. Переопределить метод всё равно обязательно.
from abc import ABC, abstractmethod
class Employee(ABC):
@abstractmethod
def calculate_salary(self) -> float:
print("Выполняется расчёт зарплаты")
return 0
class Developer(Employee):
def calculate_salary(self) -> float:
super().calculate_salary() # общая часть из базового класса
return 4000
developer = Developer()
print(developer.calculate_salary())
# Выполняется расчёт зарплаты
# 4000
Цепочка абстрактных классов
Абстрактным может быть и наследник: если он не реализовал все унаследованные абстрактные методы (или добавил свои), объект из него по-прежнему создать нельзя.
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def make_sound(self) -> str:
raise NotImplementedError
class Mammal(Animal):
# make_sound() не реализован, добавлен ещё один метод → Mammal тоже абстрактный
@abstractmethod
def feed_baby(self) -> str:
raise NotImplementedError
class Dog(Mammal):
# конечный класс реализует весь накопленный контракт
def make_sound(self) -> str:
return "Гав"
def feed_baby(self) -> str:
return "Кормит щенка молоком"
dog = Dog() # работает
# Mammal() # TypeError: Can't instantiate abstract class Mammal
В теле абстрактного метода часто пишут raise NotImplementedError вместо pass. Смысл такой: если наследник каким-то образом вызовет базовую версию, ошибка скажет прямо — «метод обязан быть переопределён».
Ошибка проектирования: метод, нужный не всем
В общий контракт нельзя помещать то, что подходит лишь части наследников:
class Animal(ABC):
@abstractmethod
def fly(self) -> None: ... # ❌ теперь и собака обязана «летать»
Класс Dog будет вынужден реализовать бессмысленный метод. Правильно — разделить контракты:
class Animal(ABC):
@abstractmethod
def eat(self) -> None: ...
class FlyingAnimal(Animal):
@abstractmethod
def fly(self) -> None: ...
Тогда Dog наследует Animal, а Bird — FlyingAnimal, и каждый реализует только то, что для него осмысленно.
Абстрактный класс как «договор»
Абстрактный класс удобен, когда несколько разных классов обязаны поддерживать один и тот же набор операций. Тогда остальной код может работать с любым из них, не зная деталей:
class PaymentProvider(ABC):
@abstractmethod
def pay(self, amount: float) -> None: ...
@abstractmethod
def refund(self, amount: float) -> None: ...
class BankProvider(PaymentProvider):
def pay(self, amount: float) -> None:
print(f"Оплата через банк: {amount} €")
def refund(self, amount: float) -> None:
print(f"Возврат через банк: {amount} €")
def checkout(provider: PaymentProvider, amount: float) -> None:
provider.pay(amount) # неважно, банк это или что-то ещё
checkout(BankProvider(), 100) # Оплата через банк: 100 €
Если позже появится CardProvider или CryptoProvider, функцию checkout() менять не придётся — она полагается на договор, а не на конкретный класс.
abc см. abc — Abstract Base Classes.
2. Документация классов
Docstring — многострочная строка сразу после объявления класса или метода. Она отображается в help() и помогает другим разработчикам быстро понять назначение класса.
class Book:
"""
Represents a book.
Attributes:
title (str): The title of the book.
author (str): The author of the book.
Methods:
get_info(): Returns a brief description of the book.
"""
def __init__(self, title: str, author: str) -> None:
self.title = title
self.author = author
def get_info(self) -> str:
"""Return a brief description of the book."""
return f"{self.title} by {self.author}"
print(Book.__doc__)
Рекомендации:
- Кратко опишите, что делает класс.
- Перечислите основные атрибуты и методы.
- Используйте единый стиль по всему проекту.
Полный docstring метода: Args, Returns, Raises
Для метода полезно описать не только назначение, но и аргументы, возвращаемое значение и исключения, которые он может выбросить:
class BankAccount:
"""
Представляет банковский счёт.
Attributes:
owner (str): Имя владельца счёта.
balance (float): Текущий баланс.
"""
def __init__(self, owner: str, balance: float = 0) -> None:
self.owner = owner
self.balance = balance
def deposit(self, amount: float) -> float:
"""
Пополняет баланс.
Args:
amount (float): Сумма пополнения.
Returns:
float: Новый баланс.
Raises:
ValueError: Если сумма не является положительной.
"""
if amount <= 0:
raise ValueError("Сумма должна быть положительной.")
self.balance += amount
return self.balance
Такую документацию Python показывает автоматически:
print(BankAccount.__doc__) # docstring класса
print(BankAccount.deposit.__doc__) # docstring метода
help(BankAccount) # полная справка по классу
help(BankAccount.deposit) # справка по одному методу
# увеличиваем баланс виден только тому, кто читает исходник. Docstring становится частью объекта: он доступен через __doc__, help() и подсказки редактора.
3. Пользовательские исключения
Встроенных исключений иногда недостаточно, чтобы точно объяснить, что пошло не так. Пользовательские исключения позволяют явно разделить ошибки бизнес-логики от стандартных.
Базовый синтаксис
class CustomError(Exception):
"""Raised when a custom business rule is violated."""
pass
raise CustomError("Error message")
Особенности
- Наследуйтесь от
Exceptionили его подклассов (ValueError,TypeErrorи др.). - Не наследуйтесь от
BaseException— он предназначен для системных исключений. - Названия принято заканчивать на
Error.
Пример: ограничение доступа по возрасту
class AccessDeniedError(Exception):
"""Raised when a user is too young to access the resource."""
pass
def check_age(age: int) -> None:
if age < 18:
raise AccessDeniedError("Access denied: age must be at least 18")
print("Access granted")
try:
check_age(16)
except AccessDeniedError as e:
print("Ошибка:", e)
Дополнительные поля в исключениях
Можно сохранять в объекте исключения полезный контекст: аргументы, вызвавшие сбой, идентификаторы и т.д.
class AccessDeniedError(Exception):
"""Raised when a user is too young to access the resource."""
def __init__(self, age: int) -> None:
self.age = age
super().__init__(f"Access denied: age {age} is too low")
def check_age(age: int) -> None:
if age < 18:
raise AccessDeniedError(age)
print("Access granted")
try:
check_age(15)
except AccessDeniedError as e:
print("Error:", e)
print("Age:", e.age)
Вызов super().__init__() передаёт сообщение в базовый Exception, поэтому print(e) работает как обычно.
Наследование от стандартных исключений
Если ошибка связана с неправильным значением, лучше наследовать её от ValueError. Это позволяет перехватывать её и как TemperatureTooLowError, и как ValueError.
class TemperatureTooLowError(ValueError):
"""Raised when temperature is below absolute zero."""
pass
def set_temperature(value: float) -> None:
if value < -273.15:
raise TemperatureTooLowError("Temperature cannot be below absolute zero")
print(f"Temperature set to {value}°C")
set_temperature(15)
set_temperature(-300) # TemperatureTooLowError
Иерархия исключений проекта
Когда своих исключений становится много, для них заводят общего предка. Тогда вызывающий код сам решает, насколько точно ловить ошибку:
class BankError(Exception):
"""Базовая ошибка банковской системы."""
class InsufficientFundsError(BankError):
"""Недостаточно средств."""
class InvalidAmountError(BankError):
"""Некорректная сумма."""
try:
account.withdraw(250)
except InsufficientFundsError:
print("Недостаточно денег") # точная реакция на конкретную ошибку
except BankError:
print("Ошибка банковской операции") # любая ошибка нашей системы
Порядок except важен: сначала более конкретные исключения, затем общий предок — иначе BankError перехватит всё первым.
Соглашение об именах: имя пользовательского исключения заканчивается на Error — PaymentError, ReceiptNotFoundError, InvalidPriceError, ClosedShiftError.
4. Магические методы
Магические (или dunder-) методы позволяют классам встраиваться в поведение самого Python: строковое представление, сравнение, арифметика, итерация и многое другое.
| Выражение | Вызываемый метод |
|---|---|
str(obj) или print(obj) | __str__ |
len(obj) | __len__ |
obj1 == obj2 | __eq__ |
obj1 + obj2 | __add__ |
item in obj | __contains__ |
obj() | __call__ |
bool(obj) | __bool__ |
Магические методы итерации
Чтобы объект можно было перебирать через for, нужно реализовать протокол итератора:
__iter__()— возвращает сам итератор.__next__()— возвращает следующее значение или выбрасываетStopIteration.
class CumulativeSum:
"""Iterator yielding running totals of a number sequence."""
def __init__(self, numbers: list[int]) -> None:
self.numbers = numbers
self.index = 0
self.total = 0
def __iter__(self):
return self
def __next__(self) -> int:
if self.index >= len(self.numbers):
raise StopIteration
self.total += self.numbers[self.index]
self.index += 1
return self.total
data = [3, 5, 2, 4]
acc = CumulativeSum(data)
for value in acc:
print(value) # 3, 8, 10, 14
Что на самом деле делает for
Цикл for — это сокращённая запись работы с итератором. Вот тот же цикл «вручную»:
iterator = iter(collection) # вызывает collection.__iter__()
while True:
try:
item = next(iterator) # вызывает iterator.__next__()
print(item)
except StopIteration: # элементы закончились
break
Поэтому __iter__ возвращает self: объект CumulativeSum уже сам является итератором, так как в нём есть __next__.
Итерация по внутреннему списку
Если класс просто хранит список, писать __next__ не нужно — достаточно вернуть готовый итератор списка:
class ShoppingCart:
def __init__(self) -> None:
self.products: list[str] = []
def add(self, product: str) -> None:
self.products.append(product)
def __iter__(self):
return iter(self.products) # стандартный итератор списка
def __reversed__(self):
return reversed(self.products)
cart = ShoppingCart()
cart.add("Телефон")
cart.add("Ноутбук")
cart.add("Наушники")
for product in cart:
print(product) # Телефон, Ноутбук, Наушники
for product in reversed(cart):
print(product) # Наушники, Ноутбук, Телефон
__reversed__ добавляет поддержку встроенной функции reversed() — обхода в обратном порядке.
StopIteration класс вроде CumulativeSum исчерпан, и повторный for ничего не выдаст. Если нужен многократный обход, __iter__ должен возвращать новый итератор при каждом вызове — как в примере с iter(self.products).